Files
ha-mcp-server/IMPLEMENTATION_NOTES.md
Felix Zösch 1761c3cdd3 Initial commit
2025-12-11 20:29:51 +01:00

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

  1. Configure Claude Desktop
  2. Start conversation
  3. Ask: "What resources do you have access to?"
  4. Test specific tools: "Turn on light.living_room"
  5. 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

  1. Clear Capability Declaration: Server announces resources and tools upfront
  2. Descriptive Schemas: All parameters have clear descriptions
  3. Consistent Error Handling: McpError with appropriate codes
  4. Type Safety: Strong typing throughout
  5. Stdio Transport: Universal compatibility
  6. Stateless Design: No session management needed
  7. Idempotent Resources: Same URI always returns same data
  8. Documented Tools: Each tool has clear purpose and examples

Next Steps for Developers

  1. Read the MCP Spec: https://modelcontextprotocol.io
  2. Study Home Assistant API: https://developers.home-assistant.io/docs/api/rest/
  3. Explore the Code: Start with index.ts, follow the flow
  4. Test Locally: Use setup.sh to get running
  5. Extend: Add the tools/resources you need
  6. Contribute: Share improvements back

Useful Resources

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! 🚀