MCP: make configurable via UI services card (BC) (#29535)

This commit is contained in:
andig 2026-05-03 11:40:50 +02:00 • committed by GitHub
parent d9cc4ecd96
commit d0d6ef0a77
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 232 additions and 9 deletions

View file

@ -12,3 +12,10 @@
- **Source**: https://github.com/H2D2-Design/h2d2-shopicons - **Source**: https://github.com/H2D2-Design/h2d2-shopicons
- **License**: Apache License 2.0 - **License**: Apache License 2.0
- **Usage**: Regular dependency - **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

View file

@ -41,7 +41,7 @@ type All struct {
SponsorToken string SponsorToken string
Plant string // telemetry plant id Plant string // telemetry plant id
Telemetry bool Telemetry bool
Mcp bool Mcp bool // TODO deprecated
Metrics bool Metrics bool
Profile bool Profile bool
Levels map[string]string Levels map[string]string

View file

@ -0,0 +1,71 @@
<template>
<GenericModal
id="mcpModal"
:title="`${$t('config.mcp.title')} 🧪`"
config-modal-name="mcp"
data-testid="mcp-modal"
>
<div v-if="!mcpActive" class="alert alert-warning mb-4" role="alert">
{{ $t("config.mcp.restartHint") }}
</div>
<p>
{{ $t("config.mcp.description") }}
<a :href="docsLink" target="_blank">{{ $t("config.general.docsLink") }}</a>
</p>
<FormRow id="mcpModalServerUrl" :label="$t('config.mcp.url')">
<input
id="mcpModalServerUrl"
type="text"
class="form-control border"
:value="mcpUrl"
readonly
/>
<CopyLink :text="mcpUrl" />
</FormRow>
<FormRow id="mcpModalExample" :label="$t('config.mcp.exampleLabel')">
<pre
id="mcpModalExample"
class="form-control border font-monospace small mb-2 mcp-example"
>{{ claudeExample }}</pre
>
<CopyLink :text="claudeExample" />
</FormRow>
</GenericModal>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import GenericModal from "../Helper/GenericModal.vue";
import CopyLink from "../Helper/CopyLink.vue";
import FormRow from "./FormRow.vue";
import store from "@/store";
import { docsPrefix } from "@/i18n";
export default defineComponent({
name: "McpModal",
components: { GenericModal, CopyLink, FormRow },
computed: {
mcpActive(): boolean {
return !!store.state?.mcp;
},
mcpUrl(): string {
return `${window.location.origin}/mcp`;
},
claudeExample(): string {
return `claude mcp add --transport http evcc ${this.mcpUrl}`;
},
docsLink(): string {
return `${docsPrefix()}/docs/integrations/mcp`;
},
},
});
</script>
<style scoped>
.mcp-example {
white-space: pre;
overflow-x: scroll;
width: 100%;
box-sizing: border-box;
}
</style>

View file

@ -0,0 +1,23 @@
<!-- Icon from Octicons by GitHub - https://github.com/primer/octicons (MIT) -->
<template>
<svg :style="svgStyle" viewBox="0 0 16 16">
<path
fill="currentColor"
d="M5.52 1.12a3.578 3.578 0 0 1 6.078 2.98a3.578 3.578 0 0 1 2.982 6.08l-3.292 3.293a.25.25 0 0 0 0 .354l.843.843a.749.749 0 1 1-1.06 1.06l-.844-.843a1.75 1.75 0 0 1 0-2.474L13.52 9.12a2.08 2.08 0 0 0 0-2.94a2.08 2.08 0 0 0-2.94 0L7.731 9.03A.75.75 0 0 1 6.67 7.97l2.85-2.85a2.08 2.08 0 0 0 0-2.94a2.08 2.08 0 0 0-2.94 0l-4.799 4.8A.75.75 0 0 1 .72 5.92Z"
/>
<path
fill="currentColor"
d="M7.52 3.12a.749.749 0 1 1 1.06 1.06L5.731 7.03A2.079 2.079 0 0 0 8.67 9.97l2.85-2.85a.749.749 0 1 1 1.06 1.06l-2.849 2.85A3.578 3.578 0 0 1 4.67 5.97Z"
/>
</svg>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import icon from "@/mixins/icon";
export default defineComponent({
name: "Mcp",
mixins: [icon],
});
</script>

View file

@ -115,6 +115,7 @@ export interface State {
database?: string; database?: string;
ocpp?: Ocpp; ocpp?: Ocpp;
optimizer?: boolean; optimizer?: boolean;
mcp?: boolean;
} }
export interface ConfigStatus<C, S> { export interface ConfigStatus<C, S> {

View file

@ -378,6 +378,15 @@
> >
<template #icon><EebusIcon /></template> <template #icon><EebusIcon /></template>
</DeviceCard> </DeviceCard>
<DeviceCard
v-if="experimental"
:title="`${$t('config.mcp.title')} 🧪`"
editable
data-testid="mcp"
@edit="openModal('mcp')"
>
<template #icon><McpIcon /></template>
</DeviceCard>
</div> </div>
<hr class="my-5" /> <hr class="my-5" />
@ -429,6 +438,7 @@
<TariffModal :currency="currency" @changed="tariffChanged" /> <TariffModal :currency="currency" @changed="tariffChanged" />
<TelemetryModal :is-sponsor="isSponsor" :telemetry="telemetry" /> <TelemetryModal :is-sponsor="isSponsor" :telemetry="telemetry" />
<OptimizerModal :is-sponsor="isSponsor" /> <OptimizerModal :is-sponsor="isSponsor" />
<McpModal />
<ExperimentalModal :experimental="experimental" /> <ExperimentalModal :experimental="experimental" />
<RemoteModal :remote="remote" :is-sponsor="isSponsor" /> <RemoteModal :remote="remote" :is-sponsor="isSponsor" />
<TitleModal @changed="loadDirty" /> <TitleModal @changed="loadDirty" />
@ -494,6 +504,8 @@ import NetworkModal from "../components/Config/NetworkModal.vue";
import NotificationIcon from "../components/MaterialIcon/Notification.vue"; import NotificationIcon from "../components/MaterialIcon/Notification.vue";
import OptimizerIcon from "../components/MaterialIcon/Optimizer.vue"; import OptimizerIcon from "../components/MaterialIcon/Optimizer.vue";
import OptimizerModal from "../components/Config/OptimizerModal.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 restart, { performRestart } from "../restart";
import SponsorModal from "../components/Config/SponsorModal.vue"; import SponsorModal from "../components/Config/SponsorModal.vue";
import store from "../store"; import store from "../store";
@ -579,6 +591,8 @@ export default defineComponent({
NotificationIcon, NotificationIcon,
OptimizerIcon, OptimizerIcon,
OptimizerModal, OptimizerModal,
McpIcon,
McpModal,
SponsorModal, SponsorModal,
TariffsLegacyModal, TariffsLegacyModal,
TariffCard, TariffCard,

View file

@ -99,9 +99,6 @@ func init() {
rootCmd.Flags().Bool("profile", false, "Expose pprof profiles") rootCmd.Flags().Bool("profile", false, "Expose pprof profiles")
bind(rootCmd, "profile") bind(rootCmd, "profile")
rootCmd.Flags().Bool("mcp", false, "Expose MCP service (experimental)")
bind(rootCmd, "mcp")
rootCmd.Flags().Bool(flagDisableAuth, false, flagDisableAuthDescription) rootCmd.Flags().Bool(flagDisableAuth, false, flagDisableAuthDescription)
rootCmd.Flags().Bool(flagDemoMode, false, flagDemoModeDescription) rootCmd.Flags().Bool(flagDemoMode, false, flagDemoModeDescription)
rootCmd.Flags().StringVar(&customCssFile, flagCustomCss, "", flagCustomCssDescription) rootCmd.Flags().StringVar(&customCssFile, flagCustomCss, "", flagCustomCssDescription)
@ -343,7 +340,7 @@ func runRoot(cmd *cobra.Command, args []string) {
} }
// setup MCP // setup MCP
if viper.GetBool("mcp") { if isMcp() {
router := httpd.Router() router := httpd.Router()
var handler http.Handler var handler http.Handler
@ -351,7 +348,9 @@ func runRoot(cmd *cobra.Command, args []string) {
router.PathPrefix("/mcp").Handler(handler) router.PathPrefix("/mcp").Handler(handler)
} }
} }
if conf.Mcp {
log.WARN.Println("mcp: yaml config is deprecated")
}
// setup messaging // setup messaging
var pushChan chan messenger.Event var pushChan chan messenger.Event
if err == nil { 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.Timezone, Val: time.Now().Format("MST -07:00")}
valueChan <- util.Param{Key: keys.Experimental, Val: isExperimental()} valueChan <- util.Param{Key: keys.Experimental, Val: isExperimental()}
valueChan <- util.Param{Key: keys.Optimizer, Val: isOptimizer()} valueChan <- util.Param{Key: keys.Optimizer, Val: isOptimizer()}
valueChan <- util.Param{Key: keys.Mcp, Val: isMcp()}
// run shutdown functions on stop // run shutdown functions on stop
var once sync.Once var once sync.Once

View file

@ -1398,3 +1398,8 @@ func isOptimizer() bool {
b, _ := settings.Bool(keys.Optimizer) b, _ := settings.Bool(keys.Optimizer)
return b return b
} }
// isMcp returns if MCP service is enabled
func isMcp() bool {
return isExperimental()
}

View file

@ -29,6 +29,7 @@ const (
Plant = "plant" Plant = "plant"
Telemetry = "telemetry" Telemetry = "telemetry"
Optimizer = "optimizer" Optimizer = "optimizer"
Mcp = "mcp"
DemoMode = "demoMode" DemoMode = "demoMode"
Remote = "remote" Remote = "remote"
RemoteClients = "remoteClients" RemoteClients = "remoteClients"

2
go.mod
View file

@ -33,7 +33,7 @@ require (
github.com/enbility/eebus-go v0.7.0 github.com/enbility/eebus-go v0.7.0
github.com/enbility/ship-go v0.6.0 github.com/enbility/ship-go v0.6.0
github.com/enbility/spine-go v0.7.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/optimizer v0.0.0-20260411145738-bf13a64d411c
github.com/evcc-io/rct v0.2.0 github.com/evcc-io/rct v0.2.0
github.com/evcc-io/tesla-proxy-client v0.0.0-20260324063928-151fe10796ae github.com/evcc-io/tesla-proxy-client v0.0.0-20260324063928-151fe10796ae

4
go.sum
View file

@ -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/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 h1:5vFr7wOwoi5ylybUnxxBk1U3FRF2+3Sf0sKx8thxw18=
github.com/evcc-io/ocpp-go v0.0.0-20251212212612-b7f92ee0443b/go.mod h1:2kcukDdhui4u730VfnYVWuwzDLgw+mBRGDir/QAyBhg= 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.1-0.20260503092507-6199c7ad3baf h1:rnObcCvSoPJpYVJoHAxFAtzy/lMLsPN1TgK3yq6nWrU=
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/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 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/optimizer v0.0.0-20260411145738-bf13a64d411c/go.mod h1:cHMeq6GfoXQ8LxqY4LH1wn/hrgYEn+ka5RPI1CF37SY=
github.com/evcc-io/rct v0.2.0 h1:qjXKI7NKwW5YpEKEb27MwLMBaR4ne2Kd2cPV2PYTKn4= github.com/evcc-io/rct v0.2.0 h1:qjXKI7NKwW5YpEKEb27MwLMBaR4ne2Kd2cPV2PYTKn4=

View file

@ -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**.", "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!" "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": { "messaging": {
"addMessenger": "Dienst hinzufügen", "addMessenger": "Dienst hinzufügen",
"description": "Benachrichtigungen über Ladevorgänge erhalten.", "description": "Benachrichtigungen über Ladevorgänge erhalten.",

View file

@ -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**.", "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!" "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": { "messaging": {
"addMessenger": "Add service", "addMessenger": "Add service",
"description": "Receive notifications about your charging sessions.", "description": "Receive notifications about your charging sessions.",

87
tests/config-mcp.spec.ts Normal file
View file

@ -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);
});
});