17 KiB
Implementation Notes for Home Assistant MCP Server
Developer Guide for Understanding and Extending the Codebase
Architecture Overview
┌─────────────────────────────────────────────────────────────┐
│ MCP Client │
│ (Claude Desktop / Other MCP Client) │
└───────────────────────────┬─────────────────────────────────┘
│
│ stdio (JSON-RPC over stdin/stdout)
│
┌───────────────────────────▼─────────────────────────────────┐
│ MCP Server Layer │
│ (src/index.ts) │
├──────────────────────────────────────────────────────────────┤
│ │
│ ┌────────────────┐ ┌────────────────┐ ┌──────────────┐ │
│ │ Resources │ │ Tools │ │ Error │ │
│ │ Handler │ │ Handler │ │ Handler │ │
│ └────────┬───────┘ └────────┬───────┘ └──────┬───────┘ │
│ │ │ │ │
│ └───────────────────┼───────────────────┘ │
│ │ │
└───────────────────────────────┼──────────────────────────────┘
│
┌───────────────────────────────▼──────────────────────────────┐
│ Home Assistant Client │
│ (src/ha-client.ts) │
├──────────────────────────────────────────────────────────────┤
│ │
│ • HTTP Request Building • Response Parsing │
│ • Authentication Headers • Error Formatting │
│ • Type-safe Methods • Timeout Management │
│ │
└───────────────────────────────┬──────────────────────────────┘
│
│ HTTP/HTTPS + Bearer Token
│
┌───────────────────────────────▼──────────────────────────────┐
│ Home Assistant REST API │
│ (port 8123) │
└──────────────────────────────────────────────────────────────┘
Key Design Patterns
1. Layered Architecture
Layer 1: MCP Protocol Handler (src/index.ts)
- Handles MCP protocol requirements
- Validates JSON schemas
- Manages request routing
- Formats responses according to MCP spec
Layer 2: Home Assistant Client (src/ha-client.ts)
- Abstracts HTTP communication
- Provides type-safe methods
- Handles authentication
- Formats errors consistently
Layer 3: Type System (src/types.ts)
- Defines data structures
- Documents API contracts
- Enables IDE autocomplete
- Catches errors at compile time
2. Request Flow Pattern
Every MCP request follows this flow:
// 1. Request arrives via stdio
MCP Client sends JSON-RPC request
↓
// 2. Server routes to handler
server.setRequestHandler(Schema, async (request) => {
↓
// 3. Extract and validate parameters
const { param1, param2 } = request.params;
↓
// 4. Call HA client method
const result = await haClient.someMethod(param1, param2);
↓
// 5. Format as MCP response
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
↓
// 6. Error handling wraps everything
} catch (error) {
throw new McpError(ErrorCode.InternalError, formatError(error));
}
3. Error Handling Pattern
Three-tier error handling:
// Tier 1: Try-catch in handler
try {
const result = await haClient.method();
} catch (error) {
// Tier 2: Check if already MCP error
if (error instanceof McpError) throw error;
// Tier 3: Format and wrap as MCP error
throw new McpError(
ErrorCode.InternalError,
HomeAssistantClient.formatError(error)
);
}
Code Organization
index.ts Structure
// 1. Imports and initialization
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { HomeAssistantClient } from './ha-client.js';
// 2. Configuration loading
const config = loadConfig();
const haClient = new HomeAssistantClient(config);
// 3. Server initialization
const server = new Server({ name, version }, { capabilities });
// 4. Resource handlers
server.setRequestHandler(ListResourcesRequestSchema, ...);
server.setRequestHandler(ReadResourceRequestSchema, ...);
// 5. Tool handlers
server.setRequestHandler(ListToolsRequestSchema, ...);
server.setRequestHandler(CallToolRequestSchema, ...);
// 6. Startup
async function main() {
await haClient.checkAPI(); // Verify connection
const transport = new StdioServerTransport();
await server.connect(transport);
}
ha-client.ts Structure
export class HomeAssistantClient {
private client: AxiosInstance;
// Constructor sets up axios with auth
constructor(config: HAConfig) { ... }
// System information methods
async getConfig(): Promise<SystemConfig> { ... }
async getComponents(): Promise<string[]> { ... }
// State management methods
async getStates(): Promise<EntityState[]> { ... }
async getState(entityId: string): Promise<EntityState> { ... }
async setState(...): Promise<EntityState> { ... }
// Service execution methods
async callService(...): Promise<...> { ... }
// Utility methods
static formatError(error: any): string { ... }
}
Implementation Details
1. Authentication Implementation
// Configuration
const client = axios.create({
baseURL: `${baseUrl}/api`,
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
timeout: 30000,
});
// Automatic inclusion in all requests
// No need to manually add header to each request
2. Resource URI Handling
// Static resources (known at compile time)
if (uri === 'ha://states') {
const states = await haClient.getStates();
return { contents: [{ uri, mimeType: 'application/json', text: JSON.stringify(states) }] };
}
// Dynamic resources (entity-specific)
if (uri.startsWith('ha://states/')) {
const entityId = uri.replace('ha://states/', '');
const state = await haClient.getState(entityId);
return { contents: [{ uri, mimeType: 'application/json', text: JSON.stringify(state) }] };
}
3. Tool Parameter Extraction
// TypeScript type assertion with validation
const { domain, service, service_data, return_response } = args as {
domain: string;
service: string;
service_data?: Record<string, any>;
return_response?: boolean;
};
// Use with confidence (types are validated by MCP SDK against schema)
const result = await haClient.callService(domain, service, service_data, return_response);
4. Response Formatting
// All tool responses use this format
return {
content: [
{
type: 'text',
text: JSON.stringify(result, null, 2), // Pretty-printed JSON
},
],
};
// Resources use a slightly different format
return {
contents: [
{
uri: request.params.uri,
mimeType: 'application/json',
text: JSON.stringify(data, null, 2),
},
],
};
Extension Cookbook
Adding a New Tool
Step 1: Define the tool schema
// In ListToolsRequestSchema handler
{
name: 'new_tool',
description: 'Clear description of what this tool does',
inputSchema: {
type: 'object',
properties: {
param1: { type: 'string', description: 'First parameter' },
param2: { type: 'number', description: 'Second parameter' },
},
required: ['param1'],
},
}
Step 2: Implement the handler
// In CallToolRequestSchema handler, add to switch statement
case 'new_tool': {
const { param1, param2 } = args as {
param1: string;
param2?: number;
};
const result = await haClient.newMethod(param1, param2);
return {
content: [
{ type: 'text', text: JSON.stringify(result, null, 2) },
],
};
}
Step 3: Add client method (if needed)
// In ha-client.ts
async newMethod(param1: string, param2?: number): Promise<ResultType> {
const response = await this.client.get('/new_endpoint', {
params: { param1, param2 },
});
return response.data;
}
Step 4: Add types (if needed)
// In types.ts
export interface ResultType {
field1: string;
field2: number;
}
Adding a New Resource
Step 1: Declare in list
// In ListResourcesRequestSchema handler
{
uri: 'ha://new_resource',
name: 'New Resource',
description: 'Description of what this resource provides',
mimeType: 'application/json',
}
Step 2: Implement read handler
// In ReadResourceRequestSchema handler
if (uri === 'ha://new_resource') {
const data = await haClient.getNewData();
return {
contents: [
{
uri,
mimeType: 'application/json',
text: JSON.stringify(data, null, 2),
},
],
};
}
Testing Strategies
Manual Testing with MCP Inspector
# Install MCP Inspector
npm install -g @modelcontextprotocol/inspector
# Run server with inspector
npx @modelcontextprotocol/inspector node build/index.js
Direct Testing with Node
// test.ts
import { HomeAssistantClient } from './src/ha-client.js';
const client = new HomeAssistantClient({
baseUrl: 'http://homeassistant.local:8123',
accessToken: 'your_token',
});
// Test individual methods
const states = await client.getStates();
console.log(`Found ${states.length} entities`);
const light = await client.getState('light.living_room');
console.log(`Light state: ${light.state}`);
await client.callService('light', 'turn_on', {
entity_id: 'light.living_room',
brightness_pct: 50,
});
Testing with Claude Desktop
- Configure Claude Desktop
- Start conversation
- Ask: "What resources do you have access to?"
- Test specific tools: "Turn on light.living_room"
- Check Claude Desktop logs for errors
Common Pitfalls and Solutions
Pitfall 1: Forgetting async/await
// ❌ Wrong - returns Promise instead of value
const states = haClient.getStates();
// ✅ Correct
const states = await haClient.getStates();
Pitfall 2: Not handling errors
// ❌ Wrong - unhandled promise rejection
const state = await haClient.getState('invalid.entity');
// ✅ Correct
try {
const state = await haClient.getState('invalid.entity');
} catch (error) {
throw new McpError(ErrorCode.InternalError, formatError(error));
}
Pitfall 3: Incorrect URI parsing
// ❌ Wrong - doesn't handle edge cases
const entityId = uri.split('/')[2];
// ✅ Correct - robust parsing
const entityId = uri.replace('ha://states/', '');
Pitfall 4: Missing type assertions
// ❌ Wrong - args is type unknown
const domain = args.domain; // TypeScript error
// ✅ Correct - assert types
const { domain } = args as { domain: string };
Performance Optimization Tips
1. Minimize API Calls
// ❌ Inefficient - multiple calls
for (const id of entityIds) {
await haClient.getState(id);
}
// ✅ Better - single call
const states = await haClient.getStates();
const filtered = states.filter(s => entityIds.includes(s.entity_id));
2. Use Templates for Complex Queries
// ❌ Slow - fetch all, filter in Node
const states = await haClient.getStates();
const count = states.filter(s => s.state === 'on').length;
// ✅ Fast - let HA do the work
const template = "{{ states | selectattr('state', 'eq', 'on') | list | count }}";
const count = await haClient.renderTemplate(template);
3. Request Only What You Need
// ❌ Wasteful - fetches all attributes
const history = await haClient.getHistory(timestamp, entities);
// ✅ Efficient - minimal response
const history = await haClient.getHistory(
timestamp,
entities,
endTime,
true, // minimal_response
true, // no_attributes
true // significant_changes_only
);
Debugging Tips
Enable Verbose Logging
// Add to ha-client.ts constructor
this.client.interceptors.request.use(request => {
console.error(`[HA] ${request.method?.toUpperCase()} ${request.url}`);
return request;
});
this.client.interceptors.response.use(
response => {
console.error(`[HA] ${response.status} ${response.config.url}`);
return response;
},
error => {
console.error(`[HA] Error: ${error.message}`);
return Promise.reject(error);
}
);
Check MCP Message Flow
// Add to index.ts handlers
console.error('[MCP] Received request:', request.method);
console.error('[MCP] Parameters:', JSON.stringify(request.params, null, 2));
Inspect Home Assistant Logs
# SSH to Home Assistant
ssh root@homeassistant.local
# View logs
docker logs homeassistant
# Or in HA UI: Settings > System > Logs
Security Considerations
Token Security
- Never log tokens
- Never commit .env files
- Rotate tokens regularly
- Use separate tokens per application
Input Validation
- All user input is validated by JSON schema
- Entity IDs are not sanitized (HA handles this)
- Template injection is possible (by design)
- No SQL injection risk (REST API)
Network Security
- Use HTTPS in production
- Consider VPN for remote access
- Firewall HA from internet
- Use strong tokens (default is secure)
MCP Best Practices Followed
- Clear Capability Declaration: Server announces resources and tools upfront
- Descriptive Schemas: All parameters have clear descriptions
- Consistent Error Handling: McpError with appropriate codes
- Type Safety: Strong typing throughout
- Stdio Transport: Universal compatibility
- Stateless Design: No session management needed
- Idempotent Resources: Same URI always returns same data
- Documented Tools: Each tool has clear purpose and examples
Next Steps for Developers
- Read the MCP Spec: https://modelcontextprotocol.io
- Study Home Assistant API: https://developers.home-assistant.io/docs/api/rest/
- Explore the Code: Start with index.ts, follow the flow
- Test Locally: Use setup.sh to get running
- Extend: Add the tools/resources you need
- Contribute: Share improvements back
Useful Resources
- MCP SDK Docs: https://github.com/modelcontextprotocol/typescript-sdk
- Home Assistant API: https://developers.home-assistant.io/docs/api/rest/
- TypeScript Handbook: https://www.typescriptlang.org/docs/
- Axios Documentation: https://axios-http.com/docs/intro
- Zod Documentation: https://zod.dev/
Conclusion
This implementation provides a solid foundation for Home Assistant + MCP integration. The code is well-structured, type-safe, and follows best practices. Extending it should be straightforward by following the patterns established in the existing code.
The separation of concerns between MCP protocol handling, HTTP communication, and type definitions makes the codebase maintainable and testable. Each layer has a clear responsibility and can be modified independently.
Happy coding! 🚀