MCP: make configurable via UI services card (BC) (#29535)
This commit is contained in:
parent
d9cc4ecd96
commit
d0d6ef0a77
14 changed files with 232 additions and 9 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
71
assets/js/components/Config/McpModal.vue
Normal file
71
assets/js/components/Config/McpModal.vue
Normal 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>
|
||||
23
assets/js/components/MaterialIcon/Mcp.vue
Normal file
23
assets/js/components/MaterialIcon/Mcp.vue
Normal 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>
|
||||
|
|
@ -115,6 +115,7 @@ export interface State {
|
|||
database?: string;
|
||||
ocpp?: Ocpp;
|
||||
optimizer?: boolean;
|
||||
mcp?: boolean;
|
||||
}
|
||||
|
||||
export interface ConfigStatus<C, S> {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
10
cmd/root.go
10
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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ const (
|
|||
Plant = "plant"
|
||||
Telemetry = "telemetry"
|
||||
Optimizer = "optimizer"
|
||||
Mcp = "mcp"
|
||||
DemoMode = "demoMode"
|
||||
Remote = "remote"
|
||||
RemoteClients = "remoteClients"
|
||||
|
|
|
|||
2
go.mod
2
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
|
||||
|
|
|
|||
4
go.sum
4
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=
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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
87
tests/config-mcp.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue