-
Notifications
You must be signed in to change notification settings - Fork 0
Feature/mcp #2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Feature/mcp #2
Changes from all commits
30ea8f2
f3a4a0a
ff2d0f2
27f9e1e
6ffa519
4937316
aebc096
f753b1c
ef1e042
4718973
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -2,6 +2,7 @@ import { cors } from "@elysiajs/cors"; | |||||
| import { swagger } from "@elysiajs/swagger"; | ||||||
| import { Elysia } from "elysia"; | ||||||
| import figlet from "figlet"; | ||||||
| import { startMcpServer } from "./src/mcp/server"; | ||||||
| import { routes } from "./src/routes"; | ||||||
| import { initRuleEngine } from "./src/rules/engine"; | ||||||
| import { EVENTS, eventBus } from "./src/utils/eventBus"; | ||||||
|
|
@@ -41,9 +42,11 @@ eventBus.on(EVENTS.NEW_CONNECTION, () => { | |||||
|
|
||||||
| if (import.meta.main) { | ||||||
| initRuleEngine(); | ||||||
| startMcpServer(); | ||||||
|
|
||||||
| const PORT_BUN_SERVER = process.env.PORT_BUN_SERVER || 3000; | ||||||
| const PORT_WEB_SERVER = process.env.PORT_WEB_SERVER || 8080; | ||||||
| const PORT_MCP_SERVER = process.env.MCP_PORT || 3001; | ||||||
|
||||||
| const PORT_MCP_SERVER = process.env.MCP_PORT || 3001; | |
| const PORT_MCP_SERVER = process.env.PORT_MCP_SERVER || 3001; |
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,118 @@ | ||||||||||||||||
| import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; | ||||||||||||||||
| import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; | ||||||||||||||||
| import { verifyClientAuth } from "../utils/auth"; | ||||||||||||||||
| import { setAuthorization, tools } from "./tools"; | ||||||||||||||||
|
|
||||||||||||||||
| export interface McpToolResponse { | ||||||||||||||||
| content: Array<{ type: "text"; text: string }>; | ||||||||||||||||
| isError?: boolean; | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| export async function wrapToolHandler( | ||||||||||||||||
| handler: (args: Record<string, unknown>) => Promise<unknown>, | ||||||||||||||||
| args: Record<string, unknown>, | ||||||||||||||||
| ): Promise<McpToolResponse> { | ||||||||||||||||
| try { | ||||||||||||||||
| const result = await handler(args); | ||||||||||||||||
| return { | ||||||||||||||||
| content: [ | ||||||||||||||||
| { | ||||||||||||||||
| type: "text" as const, | ||||||||||||||||
| text: JSON.stringify(result, null, 2), | ||||||||||||||||
| }, | ||||||||||||||||
| ], | ||||||||||||||||
| }; | ||||||||||||||||
| } catch (error) { | ||||||||||||||||
| const message = error instanceof Error ? error.message : "Unknown error"; | ||||||||||||||||
| return { | ||||||||||||||||
| content: [ | ||||||||||||||||
| { | ||||||||||||||||
| type: "text" as const, | ||||||||||||||||
| text: JSON.stringify({ error: message }, null, 2), | ||||||||||||||||
| }, | ||||||||||||||||
| ], | ||||||||||||||||
| isError: true, | ||||||||||||||||
| }; | ||||||||||||||||
| } | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| const corsHeaders = { | ||||||||||||||||
| "Access-Control-Allow-Origin": "*", | ||||||||||||||||
|
||||||||||||||||
| "Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS", | ||||||||||||||||
| "Access-Control-Allow-Headers": | ||||||||||||||||
| "Content-Type, Authorization, mcp-session-id, Last-Event-ID, mcp-protocol-version", | ||||||||||||||||
| "Access-Control-Expose-Headers": "mcp-session-id, mcp-protocol-version", | ||||||||||||||||
| }; | ||||||||||||||||
|
|
||||||||||||||||
| export async function startMcpServer() { | ||||||||||||||||
|
||||||||||||||||
| const MCP_PORT = Number(process.env.MCP_PORT) || 3001; | ||||||||||||||||
|
|
||||||||||||||||
| const server = new McpServer({ | ||||||||||||||||
| name: "myhouse-os", | ||||||||||||||||
| version: "1.0.0", | ||||||||||||||||
| }); | ||||||||||||||||
|
|
||||||||||||||||
| for (const [name, tool] of Object.entries(tools)) { | ||||||||||||||||
| server.tool(name, tool.description, tool.inputSchema.shape, async (args) => { | ||||||||||||||||
| return wrapToolHandler( | ||||||||||||||||
| tool.handler as (args: Record<string, unknown>) => Promise<unknown>, | ||||||||||||||||
| args, | ||||||||||||||||
| ); | ||||||||||||||||
| }); | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| const transport = new WebStandardStreamableHTTPServerTransport(); | ||||||||||||||||
|
|
||||||||||||||||
| await server.connect(transport); | ||||||||||||||||
|
|
||||||||||||||||
| Bun.serve({ | ||||||||||||||||
| port: MCP_PORT, | ||||||||||||||||
| async fetch(req) { | ||||||||||||||||
| const url = new URL(req.url); | ||||||||||||||||
|
|
||||||||||||||||
| if (req.method === "OPTIONS") { | ||||||||||||||||
| return new Response(null, { status: 204, headers: corsHeaders }); | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| if (url.pathname === "/health") { | ||||||||||||||||
| return new Response(JSON.stringify({ status: "ok", server: "myhouse-os-mcp" }), { | ||||||||||||||||
| headers: { ...corsHeaders, "Content-Type": "application/json" }, | ||||||||||||||||
| }); | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| if (url.pathname === "/mcp") { | ||||||||||||||||
| const authorization = req.headers.get("authorization"); | ||||||||||||||||
|
|
||||||||||||||||
| const authResult = await verifyClientAuth(authorization); | ||||||||||||||||
| if (!authResult.valid) { | ||||||||||||||||
| return new Response(JSON.stringify({ error: authResult.error }), { | ||||||||||||||||
|
||||||||||||||||
| return new Response(JSON.stringify({ error: authResult.error }), { | |
| console.warn( | |
| `MCP authentication failed for request from ${req.headers.get("x-forwarded-for") || "unknown IP"}: ${ | |
| authResult.error || "Unknown authentication error" | |
| }`, | |
| ); | |
| return new Response(JSON.stringify({ error: "Authentication failed" }), { |
Copilot
AI
Dec 28, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Logging the authenticated client ID on every MCP request could create excessive log entries and make it difficult to identify actual security issues. Consider using a different log level (e.g., debug) for routine successful authentications, or only log authentication events on first connection or failures.
| console.log(`MCP request from authenticated client: ${authResult.clientId}`); | |
| console.debug(`MCP request from authenticated client: ${authResult.clientId}`); |
Copilot
AI
Dec 28, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The authorization header is stored in a module-level variable via setAuthorization before being used in tool handlers. This creates a race condition where concurrent requests could overwrite each other's authorization values. Since the MCP server can handle multiple requests concurrently, one client's credentials could be used for another client's request. The authorization should be passed through the request context or as a parameter to the tool handlers.
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,170 @@ | ||||||||||||||
| import { z } from "zod"; | ||||||||||||||
|
|
||||||||||||||
| const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3000"; | ||||||||||||||
|
|
||||||||||||||
| let currentAuthorization = ""; | ||||||||||||||
|
||||||||||||||
|
|
||||||||||||||
| export function setAuthorization(auth: string) { | ||||||||||||||
| currentAuthorization = auth; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| async function apiCall( | ||||||||||||||
| endpoint: string, | ||||||||||||||
| method: "GET" | "POST", | ||||||||||||||
| body?: Record<string, unknown>, | ||||||||||||||
| ): Promise<Response> { | ||||||||||||||
| const options: RequestInit = { | ||||||||||||||
| method, | ||||||||||||||
| headers: { | ||||||||||||||
| Authorization: currentAuthorization, | ||||||||||||||
| "Content-Type": "application/json", | ||||||||||||||
| }, | ||||||||||||||
| }; | ||||||||||||||
|
|
||||||||||||||
| if (body) { | ||||||||||||||
| options.body = JSON.stringify(body); | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| return fetch(`${API_BASE_URL}${endpoint}`, options); | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| export const tools = { | ||||||||||||||
| toggle_light: { | ||||||||||||||
| description: "Toggle the light on or off.", | ||||||||||||||
| inputSchema: z.object({}), | ||||||||||||||
| handler: async () => { | ||||||||||||||
| const response = await apiCall("/toggle/light", "POST"); | ||||||||||||||
| const data = (await response.json()) as { light: boolean; error?: string }; | ||||||||||||||
|
|
||||||||||||||
| if (!response.ok) { | ||||||||||||||
| throw new Error(data.error || "Failed to toggle light"); | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| return { | ||||||||||||||
| success: true, | ||||||||||||||
| light: data.light, | ||||||||||||||
| message: data.light ? "Light is now ON" : "Light is now OFF", | ||||||||||||||
| }; | ||||||||||||||
| }, | ||||||||||||||
| }, | ||||||||||||||
|
|
||||||||||||||
| toggle_door: { | ||||||||||||||
| description: "Open or close the door.", | ||||||||||||||
| inputSchema: z.object({}), | ||||||||||||||
| handler: async () => { | ||||||||||||||
| const response = await apiCall("/toggle/door", "POST"); | ||||||||||||||
| const data = (await response.json()) as { door: boolean; error?: string }; | ||||||||||||||
|
|
||||||||||||||
| if (!response.ok) { | ||||||||||||||
| throw new Error(data.error || "Failed to toggle door"); | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| return { | ||||||||||||||
| success: true, | ||||||||||||||
| door: data.door, | ||||||||||||||
| message: data.door ? "Door is now OPEN" : "Door is now CLOSED", | ||||||||||||||
| }; | ||||||||||||||
| }, | ||||||||||||||
| }, | ||||||||||||||
|
|
||||||||||||||
| toggle_heat: { | ||||||||||||||
| description: "Turn the heating on or off.", | ||||||||||||||
| inputSchema: z.object({}), | ||||||||||||||
| handler: async () => { | ||||||||||||||
| const response = await apiCall("/toggle/heat", "POST"); | ||||||||||||||
| const data = (await response.json()) as { heat: boolean; error?: string }; | ||||||||||||||
|
|
||||||||||||||
| if (!response.ok) { | ||||||||||||||
| throw new Error(data.error || "Failed to toggle heat"); | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| return { | ||||||||||||||
| success: true, | ||||||||||||||
| heat: data.heat, | ||||||||||||||
| message: data.heat ? "Heating is now ON" : "Heating is now OFF", | ||||||||||||||
| }; | ||||||||||||||
| }, | ||||||||||||||
| }, | ||||||||||||||
|
|
||||||||||||||
| set_temperature: { | ||||||||||||||
| description: "Set the current temperature reading (e.g., from a sensor).", | ||||||||||||||
| inputSchema: z.object({ | ||||||||||||||
| temp: z.string().describe("Temperature value as string (e.g., '23.5')"), | ||||||||||||||
| }), | ||||||||||||||
| handler: async (args: { temp: string }) => { | ||||||||||||||
| const response = await apiCall("/temp", "POST", { temp: args.temp }); | ||||||||||||||
| const data = (await response.json()) as { temp: string; error?: string }; | ||||||||||||||
|
|
||||||||||||||
| if (!response.ok) { | ||||||||||||||
| throw new Error(data.error || "Failed to set temperature"); | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| return { | ||||||||||||||
| success: true, | ||||||||||||||
| temperature: data.temp, | ||||||||||||||
| message: `Temperature set to ${data.temp}`, | ||||||||||||||
| }; | ||||||||||||||
| }, | ||||||||||||||
| }, | ||||||||||||||
|
|
||||||||||||||
| get_home_state: { | ||||||||||||||
| description: | ||||||||||||||
| "Get the current state of the home including temperature, light, door, and heating status.", | ||||||||||||||
| inputSchema: z.object({}), | ||||||||||||||
| handler: async () => { | ||||||||||||||
| const [lightRes, doorRes, heatRes, tempRes] = await Promise.all([ | ||||||||||||||
| apiCall("/toggle/light", "GET"), | ||||||||||||||
| apiCall("/toggle/door", "GET"), | ||||||||||||||
| apiCall("/toggle/heat", "GET"), | ||||||||||||||
|
Comment on lines
+116
to
+118
|
||||||||||||||
| apiCall("/toggle/light", "GET"), | |
| apiCall("/toggle/door", "GET"), | |
| apiCall("/toggle/heat", "GET"), | |
| apiCall("/toggle/light", "POST"), | |
| apiCall("/toggle/door", "POST"), | |
| apiCall("/toggle/heat", "POST"), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The MCP server is started without error handling. If
startMcpServer()throws an error or fails to start, it could crash the entire application or leave it in an inconsistent state. Consider wrapping the call in a try-catch block and handling potential startup failures gracefully, possibly with fallback behavior or proper error logging.