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
- **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

View file

@ -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

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;
ocpp?: Ocpp;
optimizer?: boolean;
mcp?: boolean;
}
export interface ConfigStatus<C, S> {

View file

@ -378,6 +378,15 @@
>
<template #icon><EebusIcon /></template>
</DeviceCard>
<DeviceCard
v-if="experimental"
:title="`${$t('config.mcp.title')} 🧪`"
editable
data-testid="mcp"
@edit="openModal('mcp')"
>
<template #icon><McpIcon /></template>
</DeviceCard>
</div>
<hr class="my-5" />
@ -429,6 +438,7 @@
<TariffModal :currency="currency" @changed="tariffChanged" />
<TelemetryModal :is-sponsor="isSponsor" :telemetry="telemetry" />
<OptimizerModal :is-sponsor="isSponsor" />
<McpModal />
<ExperimentalModal :experimental="experimental" />
<RemoteModal :remote="remote" :is-sponsor="isSponsor" />
<TitleModal @changed="loadDirty" />
@ -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,

View file

@ -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

View file

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

View file

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

2
go.mod
View file

@ -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

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/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=

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**.",
"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.",

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**.",
"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.",

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