diff --git a/LICENSES/icons.md b/LICENSES/icons.md index 520c9b658..e0f61b173 100644 --- a/LICENSES/icons.md +++ b/LICENSES/icons.md @@ -12,3 +12,10 @@ - **Source**: https://github.com/H2D2-Design/h2d2-shopicons - **License**: Apache License 2.0 - **Usage**: Regular dependency + +## Octicons + +- **Source**: https://github.com/primer/octicons +- **License**: MIT License +- **Files**: /assets/js/components/MaterialIcon/Mcp.vue +- **Notes**: Repackaged as Vue components diff --git a/api/globalconfig/types.go b/api/globalconfig/types.go index 4c91edcc5..2d4fd7bf5 100644 --- a/api/globalconfig/types.go +++ b/api/globalconfig/types.go @@ -41,7 +41,7 @@ type All struct { SponsorToken string Plant string // telemetry plant id Telemetry bool - Mcp bool + Mcp bool // TODO deprecated Metrics bool Profile bool Levels map[string]string diff --git a/assets/js/components/Config/McpModal.vue b/assets/js/components/Config/McpModal.vue new file mode 100644 index 000000000..577e2d3cd --- /dev/null +++ b/assets/js/components/Config/McpModal.vue @@ -0,0 +1,71 @@ + + + + + diff --git a/assets/js/components/MaterialIcon/Mcp.vue b/assets/js/components/MaterialIcon/Mcp.vue new file mode 100644 index 000000000..27955a4bb --- /dev/null +++ b/assets/js/components/MaterialIcon/Mcp.vue @@ -0,0 +1,23 @@ + + + + diff --git a/assets/js/types/evcc.ts b/assets/js/types/evcc.ts index 895b78527..e438071f1 100644 --- a/assets/js/types/evcc.ts +++ b/assets/js/types/evcc.ts @@ -115,6 +115,7 @@ export interface State { database?: string; ocpp?: Ocpp; optimizer?: boolean; + mcp?: boolean; } export interface ConfigStatus { diff --git a/assets/js/views/Config.vue b/assets/js/views/Config.vue index ace35d5b9..e2d0cca38 100644 --- a/assets/js/views/Config.vue +++ b/assets/js/views/Config.vue @@ -378,6 +378,15 @@ > + + +
@@ -429,6 +438,7 @@ + @@ -494,6 +504,8 @@ import NetworkModal from "../components/Config/NetworkModal.vue"; import NotificationIcon from "../components/MaterialIcon/Notification.vue"; import OptimizerIcon from "../components/MaterialIcon/Optimizer.vue"; import OptimizerModal from "../components/Config/OptimizerModal.vue"; +import McpIcon from "../components/MaterialIcon/Mcp.vue"; +import McpModal from "../components/Config/McpModal.vue"; import restart, { performRestart } from "../restart"; import SponsorModal from "../components/Config/SponsorModal.vue"; import store from "../store"; @@ -579,6 +591,8 @@ export default defineComponent({ NotificationIcon, OptimizerIcon, OptimizerModal, + McpIcon, + McpModal, SponsorModal, TariffsLegacyModal, TariffCard, diff --git a/cmd/root.go b/cmd/root.go index be00a6c64..ef501dc4f 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -99,9 +99,6 @@ func init() { rootCmd.Flags().Bool("profile", false, "Expose pprof profiles") bind(rootCmd, "profile") - rootCmd.Flags().Bool("mcp", false, "Expose MCP service (experimental)") - bind(rootCmd, "mcp") - rootCmd.Flags().Bool(flagDisableAuth, false, flagDisableAuthDescription) rootCmd.Flags().Bool(flagDemoMode, false, flagDemoModeDescription) rootCmd.Flags().StringVar(&customCssFile, flagCustomCss, "", flagCustomCssDescription) @@ -343,7 +340,7 @@ func runRoot(cmd *cobra.Command, args []string) { } // setup MCP - if viper.GetBool("mcp") { + if isMcp() { router := httpd.Router() var handler http.Handler @@ -351,7 +348,9 @@ func runRoot(cmd *cobra.Command, args []string) { router.PathPrefix("/mcp").Handler(handler) } } - + if conf.Mcp { + log.WARN.Println("mcp: yaml config is deprecated") + } // setup messaging var pushChan chan messenger.Event if err == nil { @@ -405,6 +404,7 @@ func runRoot(cmd *cobra.Command, args []string) { valueChan <- util.Param{Key: keys.Timezone, Val: time.Now().Format("MST -07:00")} valueChan <- util.Param{Key: keys.Experimental, Val: isExperimental()} valueChan <- util.Param{Key: keys.Optimizer, Val: isOptimizer()} + valueChan <- util.Param{Key: keys.Mcp, Val: isMcp()} // run shutdown functions on stop var once sync.Once diff --git a/cmd/setup.go b/cmd/setup.go index c4d173a43..02947b145 100644 --- a/cmd/setup.go +++ b/cmd/setup.go @@ -1398,3 +1398,8 @@ func isOptimizer() bool { b, _ := settings.Bool(keys.Optimizer) return b } + +// isMcp returns if MCP service is enabled +func isMcp() bool { + return isExperimental() +} diff --git a/core/keys/global.go b/core/keys/global.go index 354ad27bc..2be399c4d 100644 --- a/core/keys/global.go +++ b/core/keys/global.go @@ -29,6 +29,7 @@ const ( Plant = "plant" Telemetry = "telemetry" Optimizer = "optimizer" + Mcp = "mcp" DemoMode = "demoMode" Remote = "remote" RemoteClients = "remoteClients" diff --git a/go.mod b/go.mod index c9c4aeb83..299af00b5 100644 --- a/go.mod +++ b/go.mod @@ -33,7 +33,7 @@ require ( github.com/enbility/eebus-go v0.7.0 github.com/enbility/ship-go v0.6.0 github.com/enbility/spine-go v0.7.0 - github.com/evcc-io/openapi-mcp v0.6.0 + github.com/evcc-io/openapi-mcp v0.6.1-0.20260503092507-6199c7ad3baf github.com/evcc-io/optimizer v0.0.0-20260411145738-bf13a64d411c github.com/evcc-io/rct v0.2.0 github.com/evcc-io/tesla-proxy-client v0.0.0-20260324063928-151fe10796ae diff --git a/go.sum b/go.sum index 205b7590b..3b233b55a 100644 --- a/go.sum +++ b/go.sum @@ -200,8 +200,8 @@ github.com/evcc-io/modbus v0.0.0-20250501165638-8b6f1fbdb7ea h1:F6eyC8V8wvc3ranl github.com/evcc-io/modbus v0.0.0-20250501165638-8b6f1fbdb7ea/go.mod h1:swrNGAVgI1r/3d/sEKE7qgujdRR9aHVPYKyc3gvpVTc= github.com/evcc-io/ocpp-go v0.0.0-20251212212612-b7f92ee0443b h1:5vFr7wOwoi5ylybUnxxBk1U3FRF2+3Sf0sKx8thxw18= github.com/evcc-io/ocpp-go v0.0.0-20251212212612-b7f92ee0443b/go.mod h1:2kcukDdhui4u730VfnYVWuwzDLgw+mBRGDir/QAyBhg= -github.com/evcc-io/openapi-mcp v0.6.0 h1:cq8CWG+3gNQ9ID0ZE8MoDHZxgdJmBZ1fFyt5xAfTRfA= -github.com/evcc-io/openapi-mcp v0.6.0/go.mod h1:YyOx4zwr6xVUcchAETHERZ+75cZMIrdcjVIZFwBlE1Q= +github.com/evcc-io/openapi-mcp v0.6.1-0.20260503092507-6199c7ad3baf h1:rnObcCvSoPJpYVJoHAxFAtzy/lMLsPN1TgK3yq6nWrU= +github.com/evcc-io/openapi-mcp v0.6.1-0.20260503092507-6199c7ad3baf/go.mod h1:YyOx4zwr6xVUcchAETHERZ+75cZMIrdcjVIZFwBlE1Q= github.com/evcc-io/optimizer v0.0.0-20260411145738-bf13a64d411c h1:KUJqCEBzEXepnL9mVpIepJF6+Vlx3jki+8y0S+mqpWc= github.com/evcc-io/optimizer v0.0.0-20260411145738-bf13a64d411c/go.mod h1:cHMeq6GfoXQ8LxqY4LH1wn/hrgYEn+ka5RPI1CF37SY= github.com/evcc-io/rct v0.2.0 h1:qjXKI7NKwW5YpEKEb27MwLMBaR4ne2Kd2cPV2PYTKn4= diff --git a/i18n/de.json b/i18n/de.json index c528319b9..0109436bd 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -429,6 +429,13 @@ "welcomeBannerText": "Lege zunächst eine **Wallbox**, **Heizung**, **Netzzähler**, **Solar**, **Batterie** oder **zusätzlichen Zähler** an. Falls du nur testen möchtest, wähle ein **Demogerät**.", "welcomeBannerTitle": "Los geht's mit der Konfiguration!" }, + "mcp": { + "description": "Stellt einen Model Context Protocol Server bereit. Damit können KI-Assistenten wie Claude den Zustand deines Systems auslesen und das Laden steuern.", + "exampleLabel": "Beispiel: Claude CLI", + "restartHint": "Wird nach Neustart verfügbar.", + "title": "MCP Server", + "url": "MCP-Endpunkt" + }, "messaging": { "addMessenger": "Dienst hinzufügen", "description": "Benachrichtigungen über Ladevorgänge erhalten.", diff --git a/i18n/en.json b/i18n/en.json index 3fb0694d8..21c535d13 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -429,6 +429,13 @@ "welcomeBannerText": "Start with creating at least one **charger**, **heater**, **grid**, **solar**, **battery** or **additional meter**. If you just want to test, pick a **demo device**.", "welcomeBannerTitle": "Let's configure your system!" }, + "mcp": { + "description": "Exposes a Model Context Protocol server, allowing AI assistants like Claude to read the state of your system and control charging.", + "exampleLabel": "Example: Claude CLI", + "restartHint": "Will be available after restart.", + "title": "MCP Server", + "url": "MCP endpoint" + }, "messaging": { "addMessenger": "Add service", "description": "Receive notifications about your charging sessions.", diff --git a/tests/config-mcp.spec.ts b/tests/config-mcp.spec.ts new file mode 100644 index 000000000..af0912b17 --- /dev/null +++ b/tests/config-mcp.spec.ts @@ -0,0 +1,87 @@ +import { test, expect } from "@playwright/test"; +import { start, stop, restart, baseUrl } from "./evcc"; +import { expectModalVisible, expectModalHidden } from "./utils"; +import axios from "axios"; + +const CONFIG = "config-grid-only.evcc.yaml"; +const MCP_URL = `${baseUrl()}/mcp`; + +test.use({ baseURL: baseUrl() }); + +test.beforeEach(async () => { + await start(CONFIG); +}); + +test.afterEach(async () => { + await stop(); +}); + +const initRequest = { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2024-11-05", + capabilities: {}, + clientInfo: { name: "playwright", version: "1" }, + }, +}; + +const initHeaders = { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", +}; + +test.describe("mcp", () => { + test("/mcp not available without experimental", async () => { + const response = await axios.post(MCP_URL, initRequest, { + headers: initHeaders, + validateStatus: () => true, + }); + expect(response.status).toBe(404); + }); + + test("enable experimental, restart, verify endpoint", async ({ page }) => { + await page.goto("/#/config"); + + // MCP card not shown yet + await expect(page.getByTestId("mcp")).toHaveCount(0); + + // enable experimental + const experimentalEntry = page.getByTestId("generalconfig-experimental"); + await experimentalEntry.getByRole("button", { name: "edit" }).click(); + const experimentalModal = page.getByTestId("experimental-modal"); + await expectModalVisible(experimentalModal); + await experimentalModal.getByLabel("Enable experimental features.").click(); + await experimentalModal.getByRole("button", { name: "Close" }).click(); + await expectModalHidden(experimentalModal); + + // MCP card now visible (Services section, gated on experimental) + const mcpCard = page.getByTestId("mcp"); + await expect(mcpCard).toBeVisible(); + + // open MCP modal — pre-restart, warning is shown and endpoint is announced + await mcpCard.getByRole("button", { name: "edit" }).click(); + const mcpModal = page.getByTestId("mcp-modal"); + await expectModalVisible(mcpModal); + await expect(mcpModal).toContainText("Will be available after restart."); + const mcpUrl = await mcpModal.getByLabel("MCP endpoint").inputValue(); + expect(mcpUrl).toBe(MCP_URL); + await mcpModal.getByRole("button", { name: "Close" }).click(); + await expectModalHidden(mcpModal); + + // restart and re-open modal — warning should be gone + await restart(CONFIG); + await page.reload(); + await page.getByTestId("mcp").getByRole("button", { name: "edit" }).click(); + await expectModalVisible(mcpModal); + await expect(mcpModal).not.toContainText("Will be available after restart."); + + // hit the endpoint at the URL the modal advertises + const response = await axios.post(mcpUrl, initRequest, { + headers: initHeaders, + validateStatus: () => true, + }); + expect(response.status).toBe(200); + }); +});