Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,5 @@ COPY --from=prerelease /usr/src/app/prisma ./prisma
# run the app
USER bun
EXPOSE 3000/tcp
EXPOSE 3001/tcp
ENTRYPOINT [ "bun", "run", "index.ts" ]
160 changes: 159 additions & 1 deletion bun.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ services:
container_name: MyHouseServer
ports:
- "0.0.0.0:3000:3000"
- "0.0.0.0:3001:3001"
environment:
- NODE_ENV=production
- DATABASE_URL=postgresql://root:root@db:5432/myhouse
Expand Down
4 changes: 4 additions & 0 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -41,9 +42,11 @@ eventBus.on(EVENTS.NEW_CONNECTION, () => {

if (import.meta.main) {
initRuleEngine();
startMcpServer();
Copy link

Copilot AI Dec 28, 2025

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.

Suggested change
startMcpServer();
try {
startMcpServer();
} catch (error) {
console.error("Failed to start MCP server", error);
}

Copilot uses AI. Check for mistakes.

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;
Copy link

Copilot AI Dec 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The environment variable name is inconsistent. The code reads process.env.MCP_PORT but the banner displays PORT_MCP_SERVER which suggests there might be confusion about the correct variable name. Either both should use MCP_PORT or both should use PORT_MCP_SERVER to avoid confusion when configuring the application.

Suggested change
const PORT_MCP_SERVER = process.env.MCP_PORT || 3001;
const PORT_MCP_SERVER = process.env.PORT_MCP_SERVER || 3001;

Copilot uses AI. Check for mistakes.
app.listen(PORT_BUN_SERVER);

console.log(`
Expand All @@ -54,6 +57,7 @@ if (import.meta.main) {
│ 🔗 API: http://192.168.4.2:${PORT_BUN_SERVER} │
│ 📖 Swagger: http://192.168.4.2:${PORT_BUN_SERVER}/swagger │
│ 🔌 WebSocket: ws://192.168.4.2:${PORT_BUN_SERVER}/ws │
│ 🤖 MCP: http://192.168.4.2:${PORT_MCP_SERVER}/mcp │
│ 🌐 Web Server: http://192.168.4.3:${PORT_WEB_SERVER} │
└────────────────────────────────────────────────────┘
`);
Expand Down
7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
"prisma:push": "bunx --bun prisma db push",
"seed": "bun run prisma/seed.ts",
"test": "bun test --timeout 5000",
"test:coverage": "bun test --coverage --timeout 5000"
"test:coverage": "bun test --coverage --timeout 5000",
"mcp": "bun run src/mcp/server.ts"
},
"devDependencies": {
"@biomejs/biome": "2.3.9",
Expand All @@ -30,12 +31,14 @@
"dependencies": {
"@elysiajs/cors": "^1.4.0",
"@elysiajs/swagger": "^1.3.1",
"@modelcontextprotocol/sdk": "^1.25.1",
"@prisma/adapter-pg": "^7.1.0",
"@prisma/client": "^7.1.0",
"@types/pg": "^8.16.0",
"elysia": "^1.4.19",
"figlet": "^1.9.4",
"pg": "^8.16.3",
"prisma": "^7.1.0"
"prisma": "^7.1.0",
"zod": "^4.2.1"
}
}
6 changes: 0 additions & 6 deletions prisma/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,16 @@ const connectionString = process.env.DATABASE_URL;
// biome-ignore lint/suspicious/noExplicitAny: Dynamic prisma type for test/prod
type PrismaType = PrismaClient | any;

// Container object - les tests peuvent modifier db.prisma directement
// et tous les modules qui utilisent db.prisma verront le changement
export const db: { prisma: PrismaType } = {
prisma: {} as PrismaType,
};

if (connectionString) {
// Production/Development: use real database connection
const pool = new Pool({ connectionString });
const adapter = new PrismaPg(pool);
db.prisma = new PrismaClient({ adapter });
}

// Export pour compatibilité avec le code existant
// Note: cet export est une référence à db.prisma, donc si db.prisma change,
// ce changement sera visible partout
export const prisma = new Proxy({} as PrismaType, {
get(_target, prop) {
return (db.prisma as Record<string | symbol, unknown>)[prop];
Expand Down
118 changes: 118 additions & 0 deletions src/mcp/server.ts
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": "*",
Copy link

Copilot AI Dec 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CORS configuration allows all origins with "*", which is overly permissive for an MCP server that handles authenticated requests. This could expose the MCP endpoints to unauthorized cross-origin requests. Consider restricting the allowed origins to specific trusted domains or making this configurable via environment variables.

Copilot uses AI. Check for mistakes.
"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() {
Copy link

Copilot AI Dec 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The startMcpServer function doesn't return the server instance or provide any way to gracefully shut it down. This makes it impossible to properly clean up resources when the application stops. Consider returning the server instance or a cleanup function to allow for graceful shutdown during application termination.

Copilot uses AI. Check for mistakes.
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 }), {
Copy link

Copilot AI Dec 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error response from the MCP server exposes the specific error message from the authentication failure. This could leak information about the authentication system to potential attackers. Consider returning a generic error message like "Authentication failed" without exposing the specific reason (e.g., "Authorization header missing" vs "Invalid credentials").

Suggested change
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 uses AI. Check for mistakes.
status: 401,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
console.log(`MCP request from authenticated client: ${authResult.clientId}`);
Copy link

Copilot AI Dec 28, 2025

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.

Suggested change
console.log(`MCP request from authenticated client: ${authResult.clientId}`);
console.debug(`MCP request from authenticated client: ${authResult.clientId}`);

Copilot uses AI. Check for mistakes.

setAuthorization(authorization || "");
Copy link

Copilot AI Dec 28, 2025

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.

Copilot uses AI. Check for mistakes.

const response = await transport.handleRequest(req);

const newHeaders = new Headers(response.headers);
for (const [key, value] of Object.entries(corsHeaders)) {
newHeaders.set(key, value);
}

return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: newHeaders,
});
}

return new Response("Not Found", { status: 404, headers: corsHeaders });
},
});

console.log(`MCP server running on http://localhost:${MCP_PORT}`);
console.log(`MCP endpoint: http://localhost:${MCP_PORT}/mcp`);
console.log(`Health check: http://localhost:${MCP_PORT}/health`);
}
170 changes: 170 additions & 0 deletions src/mcp/tools.ts
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 = "";
Copy link

Copilot AI Dec 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The module-level variable currentAuthorization is shared across all requests and could lead to race conditions in a concurrent environment. If two MCP requests are processed simultaneously, they could overwrite each other's authorization headers, causing one request to use another's credentials. Consider using a request-scoped approach or passing the authorization as a parameter to the apiCall function.

Copilot uses AI. Check for mistakes.

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
Copy link

Copilot AI Dec 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The GET requests to toggle endpoints (/toggle/light, /toggle/door, /toggle/heat) in the get_home_state handler is semantically incorrect. Based on typical REST conventions, GET requests should not modify state. If these endpoints return the current state without changing it, the naming is misleading (they're called "toggle" but used to get state). If they do change state, using GET violates REST principles and could lead to unintended state changes.

Suggested change
apiCall("/toggle/light", "GET"),
apiCall("/toggle/door", "GET"),
apiCall("/toggle/heat", "GET"),
apiCall("/toggle/light", "POST"),
apiCall("/toggle/door", "POST"),
apiCall("/toggle/heat", "POST"),

Copilot uses AI. Check for mistakes.
apiCall("/temp", "GET"),
]);

const [lightData, doorData, heatData, tempData] = (await Promise.all([
lightRes.json(),
doorRes.json(),
heatRes.json(),
tempRes.json(),
])) as [{ light: boolean }, { door: boolean }, { heat: boolean }, { temp: string }];

return {
temperature: tempData.temp,
light: lightData.light,
door: doorData.door,
heat: heatData.heat,
};
},
},

get_history: {
description: "Get the history of home events (temperature changes, light/door/heat toggles).",
inputSchema: z.object({
limit: z
.number()
.optional()
.default(50)
.describe("Number of events to retrieve (default: 50)"),
}),
handler: async (args: { limit?: number }) => {
const limit = args.limit ?? 50;
const response = await apiCall(`/history?limit=${limit}`, "GET");
const data = (await response.json()) as {
data: { type: string; value: string; createdAt: string }[];
count: number;
error?: string;
};

if (!response.ok) {
throw new Error(data.error || "Failed to get history");
}

return {
count: data.count,
events: data.data.map((event) => ({
type: event.type,
value: event.value,
createdAt: event.createdAt,
})),
};
},
},
};
Loading