@coffeerunhobby/mcp-ha-connect 2.2.1 → 2.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +1 -1
- package/README.md +11 -2
- package/dist/omadaClient/client.d.ts +6 -1
- package/dist/omadaClient/client.js +12 -1
- package/dist/omadaClient/client.js.map +1 -1
- package/dist/omadaClient/diagnose.d.ts +68 -0
- package/dist/omadaClient/diagnose.js +164 -0
- package/dist/omadaClient/diagnose.js.map +1 -0
- package/dist/omadaClient/index.d.ts +6 -0
- package/dist/omadaClient/index.js +15 -0
- package/dist/omadaClient/index.js.map +1 -1
- package/dist/omadaClient/macGroups.d.ts +58 -0
- package/dist/omadaClient/macGroups.js +124 -0
- package/dist/omadaClient/macGroups.js.map +1 -0
- package/dist/tools/infra/actions.d.ts +1 -1
- package/dist/tools/infra/actions.js +1 -1
- package/dist/tools/omada/index.js +6 -0
- package/dist/tools/omada/index.js.map +1 -1
- package/dist/tools/omada/macGroups.d.ts +19 -0
- package/dist/tools/omada/macGroups.js +77 -0
- package/dist/tools/omada/macGroups.js.map +1 -0
- package/dist/tools/omada/namespace.js +8 -0
- package/dist/tools/omada/namespace.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +5 -1
- package/.codex/review-rules.md +0 -16
- package/.dockerignore +0 -8
- package/.gitattributes +0 -30
- package/.github/workflows/ci.yml +0 -193
- package/AGENTS.md +0 -389
- package/Dockerfile +0 -50
- package/docker-compose.yml +0 -23
- package/docs/CHANGELOG.md +0 -341
- package/docs/COVERAGE.md +0 -219
- package/docs/QUICK_START.md +0 -240
- package/eslint.config.js +0 -33
- package/n8n/ai-agent-safety-monitor.json +0 -355
- package/openapi.json +0 -1259
- package/scripts/check-version-sync.mjs +0 -69
- package/scripts/generate-jwt.ts +0 -121
- package/src/config.ts +0 -440
- package/src/haClient/automations.ts +0 -261
- package/src/haClient/calendars.ts +0 -94
- package/src/haClient/config.ts +0 -52
- package/src/haClient/devices.ts +0 -653
- package/src/haClient/entities.ts +0 -128
- package/src/haClient/events.ts +0 -689
- package/src/haClient/history.ts +0 -73
- package/src/haClient/index.ts +0 -336
- package/src/haClient/request.ts +0 -142
- package/src/haClient/services.ts +0 -45
- package/src/haClient/states.ts +0 -60
- package/src/haClient/updates.ts +0 -58
- package/src/haClient/wsCommand.ts +0 -44
- package/src/index.ts +0 -164
- package/src/localAI/index.ts +0 -88
- package/src/localAI/prompts.ts +0 -50
- package/src/localAI/providers/base.ts +0 -120
- package/src/localAI/providers/ollama.ts +0 -78
- package/src/localAI/providers/openai.ts +0 -86
- package/src/localAI/types.ts +0 -67
- package/src/omadaClient/auth.ts +0 -178
- package/src/omadaClient/client.ts +0 -337
- package/src/omadaClient/device.ts +0 -118
- package/src/omadaClient/index.ts +0 -310
- package/src/omadaClient/network.ts +0 -284
- package/src/omadaClient/request.ts +0 -365
- package/src/omadaClient/security.ts +0 -51
- package/src/omadaClient/site.ts +0 -344
- package/src/ownerQuestions/service.ts +0 -536
- package/src/permissions/index.ts +0 -143
- package/src/resources/index.ts +0 -213
- package/src/server/auth.ts +0 -175
- package/src/server/chatFace.ts +0 -789
- package/src/server/chatSlice.ts +0 -57
- package/src/server/common.ts +0 -170
- package/src/server/eventSubscription.ts +0 -343
- package/src/server/http.ts +0 -626
- package/src/server/instructions.ts +0 -97
- package/src/server/rateLimiter.ts +0 -227
- package/src/server/stdio.ts +0 -41
- package/src/server/stream.ts +0 -205
- package/src/tools/ai/analyzeSensors.ts +0 -33
- package/src/tools/ai/index.ts +0 -20
- package/src/tools/common.ts +0 -442
- package/src/tools/homeassistant/askOwner.ts +0 -133
- package/src/tools/homeassistant/automationControls.ts +0 -66
- package/src/tools/homeassistant/calendar.ts +0 -97
- package/src/tools/homeassistant/callService.ts +0 -25
- package/src/tools/homeassistant/controlClimate.ts +0 -68
- package/src/tools/homeassistant/controlCover.ts +0 -48
- package/src/tools/homeassistant/controlFan.ts +0 -44
- package/src/tools/homeassistant/controlLight.ts +0 -38
- package/src/tools/homeassistant/controlMediaPlayer.ts +0 -50
- package/src/tools/homeassistant/createAutomation.ts +0 -35
- package/src/tools/homeassistant/deleteAutomation.ts +0 -24
- package/src/tools/homeassistant/entityAction.ts +0 -35
- package/src/tools/homeassistant/entityGuard.ts +0 -28
- package/src/tools/homeassistant/getAllSensors.ts +0 -46
- package/src/tools/homeassistant/getAutomationTrace.ts +0 -30
- package/src/tools/homeassistant/getDomainSummary.ts +0 -29
- package/src/tools/homeassistant/getEntitiesByDomain.ts +0 -56
- package/src/tools/homeassistant/getHistory.ts +0 -24
- package/src/tools/homeassistant/getState.ts +0 -24
- package/src/tools/homeassistant/getStates.ts +0 -46
- package/src/tools/homeassistant/getVersion.ts +0 -21
- package/src/tools/homeassistant/index.ts +0 -121
- package/src/tools/homeassistant/listAutomations.ts +0 -35
- package/src/tools/homeassistant/listEntities.ts +0 -44
- package/src/tools/homeassistant/listPersons.ts +0 -46
- package/src/tools/homeassistant/sceneAndScript.ts +0 -53
- package/src/tools/homeassistant/searchEntities.ts +0 -58
- package/src/tools/homeassistant/sendNotification.ts +0 -179
- package/src/tools/homeassistant/systemTools.ts +0 -52
- package/src/tools/homeassistant/triggerAutomation.ts +0 -24
- package/src/tools/index.ts +0 -78
- package/src/tools/infra/actions.ts +0 -95
- package/src/tools/infra/index.ts +0 -138
- package/src/tools/omada/blockClient.ts +0 -28
- package/src/tools/omada/cyclePoePort.ts +0 -32
- package/src/tools/omada/deleteClient.ts +0 -27
- package/src/tools/omada/disableClientRateLimit.ts +0 -23
- package/src/tools/omada/getClient.ts +0 -14
- package/src/tools/omada/getDevice.ts +0 -14
- package/src/tools/omada/getFirewallSetting.ts +0 -14
- package/src/tools/omada/getInternetInfo.ts +0 -14
- package/src/tools/omada/getLanNetworkList.ts +0 -14
- package/src/tools/omada/getLanProfileList.ts +0 -14
- package/src/tools/omada/getPortForwardingStatus.ts +0 -26
- package/src/tools/omada/getRateLimitProfiles.ts +0 -14
- package/src/tools/omada/getSiteNtpStatus.ts +0 -14
- package/src/tools/omada/getSsidDetail.ts +0 -22
- package/src/tools/omada/getSsidList.ts +0 -21
- package/src/tools/omada/getSwitchStackDetail.ts +0 -14
- package/src/tools/omada/getThreatList.ts +0 -42
- package/src/tools/omada/getWlanGroupList.ts +0 -14
- package/src/tools/omada/graph.ts +0 -187
- package/src/tools/omada/index.ts +0 -153
- package/src/tools/omada/listBlockedClients.ts +0 -17
- package/src/tools/omada/listClients.ts +0 -14
- package/src/tools/omada/listClientsActivity.ts +0 -25
- package/src/tools/omada/listClientsPastConnections.ts +0 -44
- package/src/tools/omada/listDevices.ts +0 -14
- package/src/tools/omada/listDevicesStats.ts +0 -41
- package/src/tools/omada/listMostActiveClients.ts +0 -14
- package/src/tools/omada/listSites.ts +0 -18
- package/src/tools/omada/namespace.ts +0 -1164
- package/src/tools/omada/searchDevices.ts +0 -20
- package/src/tools/omada/setClientRateLimit.ts +0 -26
- package/src/tools/omada/setClientRateLimitProfile.ts +0 -25
- package/src/tools/omada/setSiteNtpServers.ts +0 -36
- package/src/tools/omada/setSsidEnabled.ts +0 -38
- package/src/tools/omada/unblockClient.ts +0 -26
- package/src/tools/types.ts +0 -11
- package/src/tools/utils/pagination-schema.ts +0 -27
- package/src/types/ai/index.ts +0 -11
- package/src/types/ai/types.ts +0 -67
- package/src/types/homeassistant/automations.ts +0 -43
- package/src/types/homeassistant/calendars.ts +0 -26
- package/src/types/homeassistant/config.ts +0 -14
- package/src/types/homeassistant/entities.ts +0 -14
- package/src/types/homeassistant/errors.ts +0 -33
- package/src/types/homeassistant/haConfig.ts +0 -7
- package/src/types/homeassistant/history.ts +0 -13
- package/src/types/homeassistant/index.ts +0 -46
- package/src/types/homeassistant/request.ts +0 -6
- package/src/types/homeassistant/serviceCall.ts +0 -18
- package/src/types/homeassistant/states.ts +0 -58
- package/src/types/homeassistant/updates.ts +0 -28
- package/src/types/index.ts +0 -12
- package/src/types/omada/activeClientInfo.ts +0 -17
- package/src/types/omada/clientActivity.ts +0 -32
- package/src/types/omada/clientBlockStatus.ts +0 -11
- package/src/types/omada/clientPastConnection.ts +0 -82
- package/src/types/omada/index.ts +0 -14
- package/src/types/omada/omadaApiResponse.ts +0 -5
- package/src/types/omada/omadaClientInfo.ts +0 -11
- package/src/types/omada/omadaDeviceInfo.ts +0 -11
- package/src/types/omada/omadaDeviceStats.ts +0 -31
- package/src/types/omada/omadaSiteSummary.ts +0 -5
- package/src/types/omada/oswStackDetail.ts +0 -11
- package/src/types/omada/paginatedResult.ts +0 -6
- package/src/types/omada/rateLimitProfile.ts +0 -58
- package/src/types/omada/threatInfo.ts +0 -38
- package/src/types/omada/tokenResult.ts +0 -6
- package/src/types.ts +0 -2
- package/src/utils/config-validations.ts +0 -68
- package/src/utils/jwt.ts +0 -112
- package/src/utils/logger.ts +0 -121
- package/src/utils/plainText.ts +0 -48
- package/src/utils/redact.ts +0 -59
- package/src/utils/sanitizeError.ts +0 -18
- package/src/utils/tlsDispatcher.ts +0 -69
- package/src/version.ts +0 -5
- package/tests/bdd/automation-creation.spec.ts +0 -214
- package/tests/bdd/automation-management.spec.ts +0 -267
- package/tests/bdd/calendar.spec.ts +0 -190
- package/tests/bdd/configuration.spec.ts +0 -283
- package/tests/bdd/device-control.spec.ts +0 -344
- package/tests/bdd/entity-listing.spec.ts +0 -208
- package/tests/bdd/entity-search.spec.ts +0 -183
- package/tests/bdd/history-retrieval.spec.ts +0 -195
- package/tests/bdd/notifications.spec.ts +0 -238
- package/tests/bdd/service-calls.spec.ts +0 -144
- package/tests/bdd/state-retrieval.spec.ts +0 -204
- package/tests/bench/hotpaths.bench.ts +0 -68
- package/tests/config.test.ts +0 -355
- package/tests/features/automation-creation.feature +0 -36
- package/tests/features/automation-management.feature +0 -54
- package/tests/features/calendar.feature +0 -37
- package/tests/features/configuration.feature +0 -70
- package/tests/features/device-control.feature +0 -100
- package/tests/features/entity-listing.feature +0 -51
- package/tests/features/entity-search.feature +0 -44
- package/tests/features/history-retrieval.feature +0 -38
- package/tests/features/notifications.feature +0 -37
- package/tests/features/service-calls.feature +0 -35
- package/tests/features/state-retrieval.feature +0 -37
- package/tests/fixtures/openapi-default-slice.json +0 -290
- package/tests/haClient/automationTrace.test.ts +0 -59
- package/tests/haClient/automations.test.ts +0 -149
- package/tests/haClient/config.test.ts +0 -117
- package/tests/haClient/entities.test.ts +0 -188
- package/tests/haClient/events.test.ts +0 -455
- package/tests/haClient/eventsTls.test.ts +0 -52
- package/tests/haClient/history.test.ts +0 -228
- package/tests/haClient/index.test.ts +0 -243
- package/tests/haClient/request.test.ts +0 -217
- package/tests/haClient/services.test.ts +0 -120
- package/tests/haClient/states.test.ts +0 -119
- package/tests/haClient/updates.test.ts +0 -193
- package/tests/index.test.ts +0 -160
- package/tests/integration/homeassistant.test.ts +0 -144
- package/tests/integration/omada.test.ts +0 -167
- package/tests/integration/server.test.ts +0 -151
- package/tests/localAI/index.test.ts +0 -177
- package/tests/localAI/ollama.test.ts +0 -187
- package/tests/localAI/openai.test.ts +0 -257
- package/tests/omadaClient/actions.test.ts +0 -161
- package/tests/omadaClient/clientBlocks.test.ts +0 -170
- package/tests/omadaClient/ntp.test.ts +0 -381
- package/tests/omadaClient/site.test.ts +0 -37
- package/tests/ownerQuestions/service.test.ts +0 -499
- package/tests/ownerQuestions/tools.test.ts +0 -186
- package/tests/permissions/index.test.ts +0 -217
- package/tests/resources/index.test.ts +0 -288
- package/tests/scripts/generateJwt.test.ts +0 -44
- package/tests/security/accessControl.test.ts +0 -223
- package/tests/security/authentication.test.ts +0 -117
- package/tests/security/configuration.test.ts +0 -170
- package/tests/security/cryptoTransport.test.ts +0 -172
- package/tests/security/dependencies.test.ts +0 -152
- package/tests/security/injection.test.ts +0 -117
- package/tests/security/insecureDesign.test.ts +0 -121
- package/tests/security/logging.test.ts +0 -131
- package/tests/security/resourceConsumption.test.ts +0 -142
- package/tests/server/auth.test.ts +0 -249
- package/tests/server/chatFace.test.ts +0 -320
- package/tests/server/common.test.ts +0 -84
- package/tests/server/http.test.ts +0 -561
- package/tests/server/instructions.test.ts +0 -152
- package/tests/server/memoizeJsonSchema.test.ts +0 -71
- package/tests/server/stdio.test.ts +0 -99
- package/tests/server/stream.test.ts +0 -285
- package/tests/server/toolVisibility.http.test.ts +0 -111
- package/tests/server/toolVisibility.test.ts +0 -129
- package/tests/tools/ai/analyzeSensors.test.ts +0 -227
- package/tests/tools/common.test.ts +0 -123
- package/tests/tools/homeassistant/handlers.test.ts +0 -872
- package/tests/tools/infra/invokeAction.test.ts +0 -280
- package/tests/tools/omada/graph.test.ts +0 -385
- package/tests/tools/omada/handlers.test.ts +0 -897
- package/tests/tools/omada/namespace.test.ts +0 -483
- package/tests/tools/registry.test.ts +0 -168
- package/tests/utils/config-validations.test.ts +0 -352
- package/tests/utils/jwt.test.ts +0 -115
- package/tests/utils/logger.test.ts +0 -327
- package/tests/utils/redact.test.ts +0 -105
- package/tsconfig.json +0 -24
- package/vitest.config.ts +0 -38
package/src/server/chatFace.ts
DELETED
|
@@ -1,789 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Chat face — the OpenAPI/REST surface consumed by Open WebUI (Harvey's voice).
|
|
3
|
-
*
|
|
4
|
-
* THE FACES PROBLEM (v1.7): this server answers on two doors — the MCP face
|
|
5
|
-
* (`/mcp`, ~60 tools) and this REST face (`/openapi.json` + `/api/*`). Before
|
|
6
|
-
* v1.7 the REST face was THREE hand-maintained implementations (route handlers,
|
|
7
|
-
* a permission map, and a hand-written spec object) that had to be kept in sync
|
|
8
|
-
* by hand — so it fossilized at the 8 HA tools wired in the v1.5.1 era.
|
|
9
|
-
*
|
|
10
|
-
* Now ONE binding registry drives all three:
|
|
11
|
-
* - route dispatch (`dispatchChatRequest`)
|
|
12
|
-
* - per-route RBAC (`binding.permission`, same Permission bits as MCP)
|
|
13
|
-
* - the generated spec (`buildOpenApiSpec`)
|
|
14
|
-
*
|
|
15
|
-
* Two locks decide what chat can see:
|
|
16
|
-
* 1. Code: a tool must have a binding here at all (chat-ELIGIBLE).
|
|
17
|
-
* 2. Operator: `MCP_CHAT_TOOLS=ha-core:rw,omada-read:r` selects which
|
|
18
|
-
* category:access slices are ACTIVE (unset = exactly the legacy 8 —
|
|
19
|
-
* zero-surprise upgrade).
|
|
20
|
-
*
|
|
21
|
-
* SECURITY INVARIANT (tested): ADMIN-bit tools (invokeAction) can never be
|
|
22
|
-
* chat-faced. Chat is the most prompt-injectable surface; the deploy trigger
|
|
23
|
-
* stays off it structurally, not by prompt-politeness.
|
|
24
|
-
*/
|
|
25
|
-
|
|
26
|
-
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
27
|
-
import { z } from 'zod';
|
|
28
|
-
|
|
29
|
-
import type { ChatCategory, ChatAccess, ChatToolsSlice } from './chatSlice.js';
|
|
30
|
-
|
|
31
|
-
import type { HaClient } from '../haClient/index.js';
|
|
32
|
-
import type { OmadaClient } from '../omadaClient/index.js';
|
|
33
|
-
import { Permission, hasPermission, getPermissionNames } from '../permissions/index.js';
|
|
34
|
-
import type { AuthenticatedRequest } from './auth.js';
|
|
35
|
-
import { logger } from '../utils/logger.js';
|
|
36
|
-
import { sanitizeError } from '../utils/sanitizeError.js';
|
|
37
|
-
import { VERSION } from '../version.js';
|
|
38
|
-
import type { ToolExtra } from '../tools/common.js';
|
|
39
|
-
import type { CallToolResult } from '@modelcontextprotocol/server';
|
|
40
|
-
import { createBrowseHandler, createReadHandler, browseSchema, readSchema } from '../tools/omada/graph.js';
|
|
41
|
-
import { createSetSsidEnabledHandler, setSsidEnabledSchema } from '../tools/omada/setSsidEnabled.js';
|
|
42
|
-
import { createGetDomainSummaryHandler } from '../tools/homeassistant/getDomainSummary.js';
|
|
43
|
-
import { createListAutomationsHandler } from '../tools/homeassistant/listAutomations.js';
|
|
44
|
-
import { domainSchema, automationFilterSchema } from '../tools/common.js';
|
|
45
|
-
|
|
46
|
-
// ---------------------------------------------------------------------------
|
|
47
|
-
// Categories + operator slice (MCP_CHAT_TOOLS) — parsing lives in chatSlice.ts
|
|
48
|
-
// (zero-import module) so config.ts can use it without import cycles.
|
|
49
|
-
// ---------------------------------------------------------------------------
|
|
50
|
-
|
|
51
|
-
export { CHAT_CATEGORIES, parseChatTools } from './chatSlice.js';
|
|
52
|
-
export type { ChatCategory, ChatAccess, ChatToolsSlice } from './chatSlice.js';
|
|
53
|
-
|
|
54
|
-
// ---------------------------------------------------------------------------
|
|
55
|
-
// Binding model
|
|
56
|
-
// ---------------------------------------------------------------------------
|
|
57
|
-
|
|
58
|
-
/** Clients a binding's handler may need. */
|
|
59
|
-
export interface ChatDeps {
|
|
60
|
-
haClient?: HaClient;
|
|
61
|
-
omadaClient?: OmadaClient;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
interface ChatRequestContext {
|
|
65
|
-
/** Path parameters captured from the URL template. */
|
|
66
|
-
params: Record<string, string>;
|
|
67
|
-
/** Query string parameters. */
|
|
68
|
-
query: URLSearchParams;
|
|
69
|
-
/** Parsed JSON body (POST only; undefined otherwise). */
|
|
70
|
-
body: unknown;
|
|
71
|
-
/** Caller's permission mask (from the auth middleware). */
|
|
72
|
-
permissions: number;
|
|
73
|
-
deps: ChatDeps;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
interface ChatResponse {
|
|
77
|
-
status: number;
|
|
78
|
-
body: unknown;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
export interface ChatBinding {
|
|
82
|
-
/** OpenAPI operationId; also the tool's identity for invariants/tests. */
|
|
83
|
-
operationId: string;
|
|
84
|
-
category: ChatCategory;
|
|
85
|
-
access: ChatAccess;
|
|
86
|
-
/** RBAC bit required on the route — mirrors the MCP-tool twin (BFLA guard). */
|
|
87
|
-
permission: number;
|
|
88
|
-
method: 'GET' | 'POST';
|
|
89
|
-
/** Path template, e.g. `/api/states/{entity_id}`. */
|
|
90
|
-
pathTemplate: string;
|
|
91
|
-
/** Which client the handler needs (503 when absent). */
|
|
92
|
-
requires: 'ha' | 'omada';
|
|
93
|
-
/** OpenAPI operation object (verbatim for legacy; generated for tool bindings). */
|
|
94
|
-
spec: Record<string, unknown>;
|
|
95
|
-
handle: (ctx: ChatRequestContext) => Promise<ChatResponse>;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/** Compile a `/api/x/{a}/{b}` template into a matcher returning named params. */
|
|
99
|
-
function compileMatcher(template: string): (pathname: string) => Record<string, string> | null {
|
|
100
|
-
const names: string[] = [];
|
|
101
|
-
const pattern = template.replace(/\{([^}]+)\}/g, (_m, name: string) => {
|
|
102
|
-
names.push(name);
|
|
103
|
-
return '([^/]+)';
|
|
104
|
-
});
|
|
105
|
-
const regex = new RegExp(`^${pattern}$`);
|
|
106
|
-
return (pathname: string) => {
|
|
107
|
-
const m = pathname.match(regex);
|
|
108
|
-
if (!m) return null;
|
|
109
|
-
const params: Record<string, string> = {};
|
|
110
|
-
names.forEach((name, i) => {
|
|
111
|
-
params[name] = decodeURIComponent(m[i + 1]);
|
|
112
|
-
});
|
|
113
|
-
return params;
|
|
114
|
-
};
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// ---------------------------------------------------------------------------
|
|
118
|
-
// Legacy bindings — the pre-v1.7 REST surface, verbatim.
|
|
119
|
-
//
|
|
120
|
-
// Handlers intentionally keep their original response CONTRACTS (raw HA-style
|
|
121
|
-
// JSON, not the MCP tools' paginated envelopes) — Open WebUI has consumed these
|
|
122
|
-
// shapes since v1.5.x, and Phase 1 is byte-compatible by design. The golden-file
|
|
123
|
-
// test (tests/fixtures/openapi-default-slice.json) pins the spec.
|
|
124
|
-
// ---------------------------------------------------------------------------
|
|
125
|
-
|
|
126
|
-
const entityStateArraySchema = {
|
|
127
|
-
type: 'array',
|
|
128
|
-
items: { $ref: '#/components/schemas/EntityState' },
|
|
129
|
-
};
|
|
130
|
-
|
|
131
|
-
function ha(ctx: ChatRequestContext): HaClient {
|
|
132
|
-
// `requires: 'ha'` guarantees presence; the dispatcher 503s before this runs.
|
|
133
|
-
return ctx.deps.haClient as HaClient;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
export const LEGACY_BINDINGS: ChatBinding[] = [
|
|
137
|
-
{
|
|
138
|
-
operationId: 'getStates',
|
|
139
|
-
category: 'ha-core',
|
|
140
|
-
access: 'read',
|
|
141
|
-
permission: Permission.QUERY,
|
|
142
|
-
method: 'GET',
|
|
143
|
-
pathTemplate: '/api/states',
|
|
144
|
-
requires: 'ha',
|
|
145
|
-
spec: {
|
|
146
|
-
operationId: 'getStates',
|
|
147
|
-
summary: 'Get all entity states from Home Assistant',
|
|
148
|
-
responses: {
|
|
149
|
-
'200': {
|
|
150
|
-
description: 'List of all entity states',
|
|
151
|
-
content: { 'application/json': { schema: entityStateArraySchema } },
|
|
152
|
-
},
|
|
153
|
-
},
|
|
154
|
-
},
|
|
155
|
-
handle: async (ctx) => ({ status: 200, body: await ha(ctx).getStates() }),
|
|
156
|
-
},
|
|
157
|
-
{
|
|
158
|
-
operationId: 'getState',
|
|
159
|
-
category: 'ha-core',
|
|
160
|
-
access: 'read',
|
|
161
|
-
permission: Permission.QUERY,
|
|
162
|
-
method: 'GET',
|
|
163
|
-
pathTemplate: '/api/states/{entity_id}',
|
|
164
|
-
requires: 'ha',
|
|
165
|
-
spec: {
|
|
166
|
-
operationId: 'getState',
|
|
167
|
-
summary: 'Get state of a specific entity',
|
|
168
|
-
parameters: [
|
|
169
|
-
{ name: 'entity_id', in: 'path', required: true, schema: { type: 'string' }, description: 'Entity ID (e.g., light.living_room)' },
|
|
170
|
-
],
|
|
171
|
-
responses: {
|
|
172
|
-
'200': {
|
|
173
|
-
description: 'Entity state',
|
|
174
|
-
content: { 'application/json': { schema: { $ref: '#/components/schemas/EntityState' } } },
|
|
175
|
-
},
|
|
176
|
-
},
|
|
177
|
-
},
|
|
178
|
-
handle: async (ctx) => {
|
|
179
|
-
const entityId = ctx.params.entity_id;
|
|
180
|
-
const state = await ha(ctx).getState(entityId);
|
|
181
|
-
return state
|
|
182
|
-
? { status: 200, body: state }
|
|
183
|
-
: { status: 404, body: { error: `Entity ${entityId} not found` } };
|
|
184
|
-
},
|
|
185
|
-
},
|
|
186
|
-
{
|
|
187
|
-
operationId: 'callService',
|
|
188
|
-
category: 'ha-core',
|
|
189
|
-
access: 'write',
|
|
190
|
-
permission: Permission.CONTROL,
|
|
191
|
-
method: 'POST',
|
|
192
|
-
pathTemplate: '/api/services/{domain}/{service}',
|
|
193
|
-
requires: 'ha',
|
|
194
|
-
spec: {
|
|
195
|
-
operationId: 'callService',
|
|
196
|
-
summary: 'Call a Home Assistant service',
|
|
197
|
-
parameters: [
|
|
198
|
-
{ name: 'domain', in: 'path', required: true, schema: { type: 'string' }, description: 'Service domain (e.g., light, switch)' },
|
|
199
|
-
{ name: 'service', in: 'path', required: true, schema: { type: 'string' }, description: 'Service name (e.g., turn_on, turn_off)' },
|
|
200
|
-
],
|
|
201
|
-
requestBody: {
|
|
202
|
-
content: {
|
|
203
|
-
'application/json': {
|
|
204
|
-
schema: {
|
|
205
|
-
type: 'object',
|
|
206
|
-
properties: {
|
|
207
|
-
entity_id: { type: 'string', description: 'Target entity ID' },
|
|
208
|
-
data: { type: 'object', description: 'Service data' },
|
|
209
|
-
},
|
|
210
|
-
},
|
|
211
|
-
},
|
|
212
|
-
},
|
|
213
|
-
},
|
|
214
|
-
responses: { '200': { description: 'Service call result' } },
|
|
215
|
-
},
|
|
216
|
-
handle: async (ctx) => {
|
|
217
|
-
const body = ctx.body as { entity_id?: string; data?: Record<string, unknown> } | undefined;
|
|
218
|
-
const result = await ha(ctx).callService({
|
|
219
|
-
domain: ctx.params.domain,
|
|
220
|
-
service: ctx.params.service,
|
|
221
|
-
target: body?.entity_id ? { entity_id: body.entity_id } : undefined,
|
|
222
|
-
service_data: body?.data,
|
|
223
|
-
});
|
|
224
|
-
return { status: 200, body: result };
|
|
225
|
-
},
|
|
226
|
-
},
|
|
227
|
-
{
|
|
228
|
-
operationId: 'getAllSensors',
|
|
229
|
-
category: 'ha-core',
|
|
230
|
-
access: 'read',
|
|
231
|
-
permission: Permission.QUERY,
|
|
232
|
-
method: 'GET',
|
|
233
|
-
pathTemplate: '/api/sensors',
|
|
234
|
-
requires: 'ha',
|
|
235
|
-
spec: {
|
|
236
|
-
operationId: 'getAllSensors',
|
|
237
|
-
summary: 'Get all sensor and binary_sensor states',
|
|
238
|
-
responses: {
|
|
239
|
-
'200': {
|
|
240
|
-
description: 'List of sensor states',
|
|
241
|
-
content: { 'application/json': { schema: entityStateArraySchema } },
|
|
242
|
-
},
|
|
243
|
-
},
|
|
244
|
-
},
|
|
245
|
-
handle: async (ctx) => {
|
|
246
|
-
const states = await ha(ctx).getStates();
|
|
247
|
-
const sensors = states.filter(
|
|
248
|
-
(s: { entity_id: string }) =>
|
|
249
|
-
s.entity_id.startsWith('sensor.') || s.entity_id.startsWith('binary_sensor.')
|
|
250
|
-
);
|
|
251
|
-
return { status: 200, body: sensors };
|
|
252
|
-
},
|
|
253
|
-
},
|
|
254
|
-
{
|
|
255
|
-
operationId: 'getEntitiesByDomain',
|
|
256
|
-
category: 'ha-core',
|
|
257
|
-
access: 'read',
|
|
258
|
-
permission: Permission.QUERY,
|
|
259
|
-
method: 'GET',
|
|
260
|
-
pathTemplate: '/api/entities/{domain}',
|
|
261
|
-
requires: 'ha',
|
|
262
|
-
spec: {
|
|
263
|
-
operationId: 'getEntitiesByDomain',
|
|
264
|
-
summary: 'Get all entities for a specific domain',
|
|
265
|
-
parameters: [
|
|
266
|
-
{ name: 'domain', in: 'path', required: true, schema: { type: 'string' }, description: 'Domain name (e.g., light, sensor)' },
|
|
267
|
-
],
|
|
268
|
-
responses: {
|
|
269
|
-
'200': {
|
|
270
|
-
description: 'List of entities in domain',
|
|
271
|
-
content: { 'application/json': { schema: entityStateArraySchema } },
|
|
272
|
-
},
|
|
273
|
-
},
|
|
274
|
-
},
|
|
275
|
-
handle: async (ctx) => {
|
|
276
|
-
const states = await ha(ctx).getStates();
|
|
277
|
-
const filtered = states.filter((s: { entity_id: string }) =>
|
|
278
|
-
s.entity_id.startsWith(`${ctx.params.domain}.`)
|
|
279
|
-
);
|
|
280
|
-
return { status: 200, body: filtered };
|
|
281
|
-
},
|
|
282
|
-
},
|
|
283
|
-
{
|
|
284
|
-
operationId: 'searchEntities',
|
|
285
|
-
category: 'ha-core',
|
|
286
|
-
access: 'read',
|
|
287
|
-
permission: Permission.QUERY,
|
|
288
|
-
method: 'GET',
|
|
289
|
-
pathTemplate: '/api/search',
|
|
290
|
-
requires: 'ha',
|
|
291
|
-
spec: {
|
|
292
|
-
operationId: 'searchEntities',
|
|
293
|
-
summary: 'Search entities by name or ID',
|
|
294
|
-
parameters: [
|
|
295
|
-
{ name: 'q', in: 'query', required: true, schema: { type: 'string' }, description: 'Search query' },
|
|
296
|
-
],
|
|
297
|
-
responses: {
|
|
298
|
-
'200': {
|
|
299
|
-
description: 'Matching entities',
|
|
300
|
-
content: { 'application/json': { schema: entityStateArraySchema } },
|
|
301
|
-
},
|
|
302
|
-
},
|
|
303
|
-
},
|
|
304
|
-
handle: async (ctx) => {
|
|
305
|
-
const query = ctx.query.get('q')?.toLowerCase() ?? '';
|
|
306
|
-
const states = await ha(ctx).getStates();
|
|
307
|
-
const filtered = states.filter(
|
|
308
|
-
(s: { entity_id: string; attributes?: { friendly_name?: string } }) =>
|
|
309
|
-
s.entity_id.toLowerCase().includes(query) ||
|
|
310
|
-
s.attributes?.friendly_name?.toLowerCase().includes(query)
|
|
311
|
-
);
|
|
312
|
-
return { status: 200, body: filtered };
|
|
313
|
-
},
|
|
314
|
-
},
|
|
315
|
-
{
|
|
316
|
-
operationId: 'getHistory',
|
|
317
|
-
category: 'ha-history',
|
|
318
|
-
access: 'read',
|
|
319
|
-
permission: Permission.QUERY,
|
|
320
|
-
method: 'GET',
|
|
321
|
-
pathTemplate: '/api/history/{entity_id}',
|
|
322
|
-
requires: 'ha',
|
|
323
|
-
spec: {
|
|
324
|
-
operationId: 'getHistory',
|
|
325
|
-
summary: 'Get historical data for an entity',
|
|
326
|
-
parameters: [
|
|
327
|
-
{ name: 'entity_id', in: 'path', required: true, schema: { type: 'string' }, description: 'Entity ID' },
|
|
328
|
-
{ name: 'hours', in: 'query', schema: { type: 'number', default: 24 }, description: 'Hours of history' },
|
|
329
|
-
],
|
|
330
|
-
responses: {
|
|
331
|
-
'200': {
|
|
332
|
-
description: 'Historical data',
|
|
333
|
-
content: { 'application/json': { schema: { type: 'array' } } },
|
|
334
|
-
},
|
|
335
|
-
},
|
|
336
|
-
},
|
|
337
|
-
handle: async (ctx) => {
|
|
338
|
-
const hours = parseInt(ctx.query.get('hours') ?? '24', 10);
|
|
339
|
-
const history = await ha(ctx).getHistory(ctx.params.entity_id, hours);
|
|
340
|
-
return { status: 200, body: history };
|
|
341
|
-
},
|
|
342
|
-
},
|
|
343
|
-
{
|
|
344
|
-
operationId: 'getVersion',
|
|
345
|
-
category: 'ha-core',
|
|
346
|
-
access: 'read',
|
|
347
|
-
permission: Permission.QUERY,
|
|
348
|
-
method: 'GET',
|
|
349
|
-
pathTemplate: '/api/version',
|
|
350
|
-
requires: 'ha',
|
|
351
|
-
spec: {
|
|
352
|
-
operationId: 'getVersion',
|
|
353
|
-
summary: 'Get Home Assistant version information',
|
|
354
|
-
responses: {
|
|
355
|
-
'200': {
|
|
356
|
-
description: 'Version information',
|
|
357
|
-
content: {
|
|
358
|
-
'application/json': {
|
|
359
|
-
schema: {
|
|
360
|
-
type: 'object',
|
|
361
|
-
properties: {
|
|
362
|
-
ha_version: { type: 'string', description: 'Home Assistant version' },
|
|
363
|
-
mcp_version: { type: 'string', description: 'MCP server version' },
|
|
364
|
-
},
|
|
365
|
-
},
|
|
366
|
-
},
|
|
367
|
-
},
|
|
368
|
-
},
|
|
369
|
-
},
|
|
370
|
-
},
|
|
371
|
-
handle: async (ctx) => {
|
|
372
|
-
const haConfig = await ha(ctx).getVersion();
|
|
373
|
-
return { status: 200, body: { ha_version: haConfig.version, mcp_version: VERSION } };
|
|
374
|
-
},
|
|
375
|
-
},
|
|
376
|
-
];
|
|
377
|
-
|
|
378
|
-
// The spec paths must render in the ORIGINAL order for byte-compatibility with
|
|
379
|
-
// the pre-v1.7 hand-written spec (JSON key order = insertion order). The legacy
|
|
380
|
-
// spec listed callService third; LEGACY_BINDINGS above preserves that order.
|
|
381
|
-
|
|
382
|
-
// ---------------------------------------------------------------------------
|
|
383
|
-
// Zod -> OpenAPI schema (minimal, for tool bindings only)
|
|
384
|
-
// ---------------------------------------------------------------------------
|
|
385
|
-
|
|
386
|
-
/**
|
|
387
|
-
* Convert the small zod subset our chat-faced tools use into an OpenAPI schema.
|
|
388
|
-
* Deliberately minimal (no dependency on zod-to-json-schema — adding a dep means
|
|
389
|
-
* regenerating the lockfile, a recurring cross-OS trap). Unknown types degrade
|
|
390
|
-
* to `{}` (permissive) rather than throwing.
|
|
391
|
-
*/
|
|
392
|
-
export function zodToOpenApiSchema(schema: z.ZodType): Record<string, unknown> {
|
|
393
|
-
// zod 4 exposes the schema kind on `_zod.def.type` (zod 3 used `_def.typeName`).
|
|
394
|
-
const def = (schema as unknown as { _zod: { def: { type: string } } })._zod.def;
|
|
395
|
-
|
|
396
|
-
switch (def.type) {
|
|
397
|
-
case 'optional':
|
|
398
|
-
case 'default': {
|
|
399
|
-
const inner = zodToOpenApiSchema((def as unknown as { innerType: z.ZodType }).innerType);
|
|
400
|
-
if (def.type === 'default') {
|
|
401
|
-
// zod 4 stores the default as a value (zod 3 stored a getter function).
|
|
402
|
-
const dv = (def as unknown as { defaultValue: unknown }).defaultValue;
|
|
403
|
-
return { ...inner, default: dv };
|
|
404
|
-
}
|
|
405
|
-
return inner;
|
|
406
|
-
}
|
|
407
|
-
case 'string':
|
|
408
|
-
return { type: 'string', ...describeOf(schema) };
|
|
409
|
-
case 'number':
|
|
410
|
-
return { type: 'number', ...describeOf(schema) };
|
|
411
|
-
case 'boolean':
|
|
412
|
-
return { type: 'boolean', ...describeOf(schema) };
|
|
413
|
-
case 'enum':
|
|
414
|
-
return {
|
|
415
|
-
type: 'string',
|
|
416
|
-
enum: (schema as unknown as { options: string[] }).options,
|
|
417
|
-
...describeOf(schema),
|
|
418
|
-
};
|
|
419
|
-
case 'array':
|
|
420
|
-
return {
|
|
421
|
-
type: 'array',
|
|
422
|
-
items: zodToOpenApiSchema((def as unknown as { element: z.ZodType }).element),
|
|
423
|
-
...describeOf(schema),
|
|
424
|
-
};
|
|
425
|
-
case 'record':
|
|
426
|
-
return { type: 'object', ...describeOf(schema) };
|
|
427
|
-
case 'object': {
|
|
428
|
-
const shape = (schema as z.ZodObject<z.ZodRawShape>).shape;
|
|
429
|
-
const properties: Record<string, unknown> = {};
|
|
430
|
-
const required: string[] = [];
|
|
431
|
-
for (const [key, value] of Object.entries(shape)) {
|
|
432
|
-
properties[key] = zodToOpenApiSchema(value as z.ZodType);
|
|
433
|
-
if (!(value instanceof z.ZodOptional) && !(value instanceof z.ZodDefault)) {
|
|
434
|
-
required.push(key);
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
return {
|
|
438
|
-
type: 'object',
|
|
439
|
-
properties,
|
|
440
|
-
...(required.length > 0 ? { required } : {}),
|
|
441
|
-
...describeOf(schema),
|
|
442
|
-
};
|
|
443
|
-
}
|
|
444
|
-
default:
|
|
445
|
-
return {};
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
function describeOf(schema: z.ZodType): { description?: string } {
|
|
450
|
-
const description = (schema as { description?: string }).description;
|
|
451
|
-
return description ? { description } : {};
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
// ---------------------------------------------------------------------------
|
|
455
|
-
// MCP-tool bindings — new chat tools dispatch THROUGH the shared MCP handler
|
|
456
|
-
// (single source of truth: same validation, RBAC, hints, and logging as /mcp).
|
|
457
|
-
// ---------------------------------------------------------------------------
|
|
458
|
-
|
|
459
|
-
/** Minimal synthetic ToolExtra for REST-originated tool calls. */
|
|
460
|
-
function restToolExtra(permissions: number): ToolExtra {
|
|
461
|
-
// SDK v2 context shape: auth lives under `http.authInfo` (v1 had it top-level).
|
|
462
|
-
// getCallerPermissions reads exactly this path, so a wrong shape here would make
|
|
463
|
-
// every REST call fail closed (denied).
|
|
464
|
-
return {
|
|
465
|
-
sessionId: 'chat-rest',
|
|
466
|
-
http: { authInfo: { token: '', clientId: 'chat-rest', scopes: [], extra: { permissions } } },
|
|
467
|
-
} as unknown as ToolExtra;
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
/** Map a CallToolResult back onto an HTTP response. */
|
|
471
|
-
function toolResultToResponse(result: CallToolResult): ChatResponse {
|
|
472
|
-
const text = result.content?.[0]?.type === 'text' ? result.content[0].text : '';
|
|
473
|
-
let body: unknown = text;
|
|
474
|
-
try {
|
|
475
|
-
body = JSON.parse(text);
|
|
476
|
-
} catch {
|
|
477
|
-
/* non-JSON tool output stays a plain string */
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
if (result.isError) {
|
|
481
|
-
const errName = (body as { error?: string } | undefined)?.error;
|
|
482
|
-
const status = errName === 'Permission denied' ? 403 : 500;
|
|
483
|
-
return { status, body };
|
|
484
|
-
}
|
|
485
|
-
return { status: 200, body };
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
interface McpToolBindingOptions {
|
|
489
|
-
name: string;
|
|
490
|
-
category: ChatCategory;
|
|
491
|
-
access: ChatAccess;
|
|
492
|
-
permission: number;
|
|
493
|
-
requires: 'ha' | 'omada';
|
|
494
|
-
summary: string;
|
|
495
|
-
inputSchema: z.ZodObject<z.ZodRawShape>;
|
|
496
|
-
createHandler: (deps: ChatDeps) => (args: never, extra: ToolExtra) => Promise<CallToolResult>;
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
/**
|
|
500
|
-
* Build a chat binding that exposes an MCP tool as `POST /api/tools/{name}`.
|
|
501
|
-
* The request body is the tool's arguments (validated by the tool's own zod
|
|
502
|
-
* schema); the response is the tool result's JSON payload.
|
|
503
|
-
*/
|
|
504
|
-
function mcpToolBinding(options: McpToolBindingOptions): ChatBinding {
|
|
505
|
-
const { name, category, access, permission, requires, summary, inputSchema, createHandler } = options;
|
|
506
|
-
return {
|
|
507
|
-
operationId: name,
|
|
508
|
-
category,
|
|
509
|
-
access,
|
|
510
|
-
permission,
|
|
511
|
-
method: 'POST',
|
|
512
|
-
pathTemplate: `/api/tools/${name}`,
|
|
513
|
-
requires,
|
|
514
|
-
spec: {
|
|
515
|
-
operationId: name,
|
|
516
|
-
summary,
|
|
517
|
-
requestBody: {
|
|
518
|
-
content: { 'application/json': { schema: zodToOpenApiSchema(inputSchema) } },
|
|
519
|
-
},
|
|
520
|
-
responses: { '200': { description: 'Tool result' } },
|
|
521
|
-
},
|
|
522
|
-
handle: async (ctx) => {
|
|
523
|
-
const parsed = inputSchema.safeParse(ctx.body ?? {});
|
|
524
|
-
if (!parsed.success) {
|
|
525
|
-
return {
|
|
526
|
-
status: 400,
|
|
527
|
-
body: {
|
|
528
|
-
error: 'Invalid arguments',
|
|
529
|
-
issues: parsed.error.issues.map((i) => ({ path: i.path.join('.'), message: i.message })),
|
|
530
|
-
},
|
|
531
|
-
};
|
|
532
|
-
}
|
|
533
|
-
const handler = createHandler(ctx.deps);
|
|
534
|
-
const result = await handler(parsed.data as never, restToolExtra(ctx.permissions));
|
|
535
|
-
return toolResultToResponse(result);
|
|
536
|
-
},
|
|
537
|
-
};
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
/**
|
|
541
|
-
* Chat-ELIGIBLE MCP tools. Adding an entry makes a tool *available* to the chat
|
|
542
|
-
* face; the operator's MCP_CHAT_TOOLS slice decides whether it's *active*.
|
|
543
|
-
*
|
|
544
|
-
* NEVER list ADMIN-bit tools here (invokeAction). The invariant test enforces it.
|
|
545
|
-
* omada_cyclePoePort is deliberately absent for now — hard power action.
|
|
546
|
-
*/
|
|
547
|
-
export const CHAT_TOOL_BINDINGS: ChatBinding[] = [
|
|
548
|
-
mcpToolBinding({
|
|
549
|
-
name: 'getDomainSummary',
|
|
550
|
-
category: 'ha-core',
|
|
551
|
-
access: 'read',
|
|
552
|
-
permission: Permission.QUERY,
|
|
553
|
-
requires: 'ha',
|
|
554
|
-
summary: 'Get a summary of entities in a domain (counts and state breakdown)',
|
|
555
|
-
inputSchema: domainSchema,
|
|
556
|
-
createHandler: (deps) => createGetDomainSummaryHandler(deps.haClient as HaClient),
|
|
557
|
-
}),
|
|
558
|
-
mcpToolBinding({
|
|
559
|
-
name: 'listAutomations',
|
|
560
|
-
category: 'ha-automations',
|
|
561
|
-
access: 'read',
|
|
562
|
-
permission: Permission.QUERY,
|
|
563
|
-
requires: 'ha',
|
|
564
|
-
summary: 'List Home Assistant automations with status and last-triggered time',
|
|
565
|
-
inputSchema: automationFilterSchema,
|
|
566
|
-
createHandler: (deps) => createListAutomationsHandler(deps.haClient as HaClient),
|
|
567
|
-
}),
|
|
568
|
-
mcpToolBinding({
|
|
569
|
-
name: 'omada_browse',
|
|
570
|
-
category: 'omada-read',
|
|
571
|
-
access: 'read',
|
|
572
|
-
permission: Permission.QUERY,
|
|
573
|
-
requires: 'omada',
|
|
574
|
-
summary: 'Discover the Omada network resource graph (browse from "/" downward)',
|
|
575
|
-
inputSchema: browseSchema,
|
|
576
|
-
createHandler: (deps) => createBrowseHandler(deps.omadaClient as OmadaClient),
|
|
577
|
-
}),
|
|
578
|
-
mcpToolBinding({
|
|
579
|
-
name: 'omada_read',
|
|
580
|
-
category: 'omada-read',
|
|
581
|
-
access: 'read',
|
|
582
|
-
// Route-level QUERY; each resource path enforces its own bit inside the handler.
|
|
583
|
-
permission: Permission.QUERY,
|
|
584
|
-
requires: 'omada',
|
|
585
|
-
summary: 'Read data from an Omada resource path discovered via omada_browse',
|
|
586
|
-
inputSchema: readSchema,
|
|
587
|
-
createHandler: (deps) => createReadHandler(deps.omadaClient as OmadaClient),
|
|
588
|
-
}),
|
|
589
|
-
mcpToolBinding({
|
|
590
|
-
name: 'omada_setSsidEnabled',
|
|
591
|
-
category: 'omada-write',
|
|
592
|
-
access: 'write',
|
|
593
|
-
permission: Permission.CONTROL,
|
|
594
|
-
requires: 'omada',
|
|
595
|
-
summary: 'Turn an SSID (e.g. the guest WiFi) on or off by name — reversible',
|
|
596
|
-
inputSchema: setSsidEnabledSchema,
|
|
597
|
-
createHandler: (deps) => createSetSsidEnabledHandler(deps.omadaClient as OmadaClient),
|
|
598
|
-
}),
|
|
599
|
-
];
|
|
600
|
-
|
|
601
|
-
// ---------------------------------------------------------------------------
|
|
602
|
-
// Slice resolution + spec generation + dispatch
|
|
603
|
-
// ---------------------------------------------------------------------------
|
|
604
|
-
|
|
605
|
-
/**
|
|
606
|
-
* Resolve the ACTIVE chat bindings.
|
|
607
|
-
*
|
|
608
|
-
* - Slice unset (MCP_CHAT_TOOLS not configured) -> exactly the legacy 8
|
|
609
|
-
* (pre-v1.7 behavior, byte-compatible spec).
|
|
610
|
-
* - Slice set -> it governs BOTH legacy and tool bindings by category:access.
|
|
611
|
-
* The slice can only narrow relative to what the code marks chat-eligible.
|
|
612
|
-
*/
|
|
613
|
-
export function resolveChatBindings(slice: ChatToolsSlice | undefined, deps: ChatDeps): ChatBinding[] {
|
|
614
|
-
const all = slice === undefined ? LEGACY_BINDINGS : [...LEGACY_BINDINGS, ...CHAT_TOOL_BINDINGS];
|
|
615
|
-
|
|
616
|
-
return all.filter((binding) => {
|
|
617
|
-
if (slice !== undefined) {
|
|
618
|
-
const levels = slice.get(binding.category);
|
|
619
|
-
if (!levels || !levels.has(binding.access)) {
|
|
620
|
-
return false;
|
|
621
|
-
}
|
|
622
|
-
}
|
|
623
|
-
// A binding whose client isn't configured is dropped from the face entirely
|
|
624
|
-
// (absent from the spec, 404 on the route) rather than serving 503s.
|
|
625
|
-
if (binding.requires === 'omada' && !deps.omadaClient) return false;
|
|
626
|
-
// HA bindings stay visible without a client for legacy parity (503 at call
|
|
627
|
-
// time via dispatch, matching pre-v1.7 behavior).
|
|
628
|
-
return true;
|
|
629
|
-
});
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
/** Generate the OpenAPI spec for the active chat face. */
|
|
633
|
-
export function buildOpenApiSpec(baseUrl: string, bindings: ChatBinding[]): object {
|
|
634
|
-
const paths: Record<string, Record<string, unknown>> = {};
|
|
635
|
-
for (const binding of bindings) {
|
|
636
|
-
const item = paths[binding.pathTemplate] ?? {};
|
|
637
|
-
item[binding.method.toLowerCase()] = binding.spec;
|
|
638
|
-
paths[binding.pathTemplate] = item;
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
return {
|
|
642
|
-
openapi: '3.1.0',
|
|
643
|
-
info: {
|
|
644
|
-
title: 'Home Assistant MCP Tools',
|
|
645
|
-
description: 'MCP server for Home Assistant - exposes entity states and service calls',
|
|
646
|
-
version: VERSION,
|
|
647
|
-
},
|
|
648
|
-
servers: [{ url: baseUrl }],
|
|
649
|
-
paths,
|
|
650
|
-
components: {
|
|
651
|
-
schemas: {
|
|
652
|
-
EntityState: {
|
|
653
|
-
type: 'object',
|
|
654
|
-
properties: {
|
|
655
|
-
entity_id: { type: 'string' },
|
|
656
|
-
state: { type: 'string' },
|
|
657
|
-
attributes: { type: 'object' },
|
|
658
|
-
last_changed: { type: 'string' },
|
|
659
|
-
last_updated: { type: 'string' },
|
|
660
|
-
},
|
|
661
|
-
},
|
|
662
|
-
},
|
|
663
|
-
},
|
|
664
|
-
};
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
/** Compiled matcher cache (module-level; templates are static). */
|
|
668
|
-
const matcherCache = new Map<string, (pathname: string) => Record<string, string> | null>();
|
|
669
|
-
|
|
670
|
-
function matcherFor(template: string): (pathname: string) => Record<string, string> | null {
|
|
671
|
-
let matcher = matcherCache.get(template);
|
|
672
|
-
if (!matcher) {
|
|
673
|
-
matcher = compileMatcher(template);
|
|
674
|
-
matcherCache.set(template, matcher);
|
|
675
|
-
}
|
|
676
|
-
return matcher;
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
/**
|
|
680
|
-
* Resolve the permission a REST route requires under the given bindings.
|
|
681
|
-
* Exposed for tests and for auth-layer reasoning; returns null for unknown
|
|
682
|
-
* routes (they 404 without needing a permission).
|
|
683
|
-
*/
|
|
684
|
-
export function requiredPermissionFor(
|
|
685
|
-
bindings: ChatBinding[],
|
|
686
|
-
method: string,
|
|
687
|
-
pathname: string
|
|
688
|
-
): number | null {
|
|
689
|
-
const m = method.toUpperCase();
|
|
690
|
-
for (const binding of bindings) {
|
|
691
|
-
if (binding.method !== m) continue;
|
|
692
|
-
if (matcherFor(binding.pathTemplate)(pathname)) {
|
|
693
|
-
return binding.permission;
|
|
694
|
-
}
|
|
695
|
-
}
|
|
696
|
-
return null;
|
|
697
|
-
}
|
|
698
|
-
|
|
699
|
-
export interface DispatchOptions {
|
|
700
|
-
bindings: ChatBinding[];
|
|
701
|
-
deps: ChatDeps;
|
|
702
|
-
/** Parse the request body (injected so http.ts's size-capped parser is used). */
|
|
703
|
-
parseBody: (req: IncomingMessage) => Promise<unknown>;
|
|
704
|
-
sendJson: (res: ServerResponse, status: number, body: unknown) => void;
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
/**
|
|
708
|
-
* Dispatch a `/api/*` request against the active bindings: match, enforce the
|
|
709
|
-
* route's RBAC bit (fail closed), parse the body for POSTs, run the handler.
|
|
710
|
-
* Unknown routes 404; missing clients 503 — same semantics as the pre-v1.7
|
|
711
|
-
* hand-written bridge.
|
|
712
|
-
*/
|
|
713
|
-
export async function dispatchChatRequest(
|
|
714
|
-
req: IncomingMessage,
|
|
715
|
-
res: ServerResponse,
|
|
716
|
-
url: string,
|
|
717
|
-
options: DispatchOptions
|
|
718
|
-
): Promise<void> {
|
|
719
|
-
const { bindings, deps, parseBody, sendJson } = options;
|
|
720
|
-
const parsedUrl = new URL(url, `http://${req.headers.host ?? 'localhost'}`);
|
|
721
|
-
const pathname = parsedUrl.pathname;
|
|
722
|
-
const method = (req.method ?? 'GET').toUpperCase();
|
|
723
|
-
|
|
724
|
-
// Find the matching binding.
|
|
725
|
-
let matched: { binding: ChatBinding; params: Record<string, string> } | undefined;
|
|
726
|
-
for (const binding of bindings) {
|
|
727
|
-
if (binding.method !== method) continue;
|
|
728
|
-
const params = matcherFor(binding.pathTemplate)(pathname);
|
|
729
|
-
if (params) {
|
|
730
|
-
matched = { binding, params };
|
|
731
|
-
break;
|
|
732
|
-
}
|
|
733
|
-
}
|
|
734
|
-
|
|
735
|
-
if (!matched) {
|
|
736
|
-
sendJson(res, 404, { error: 'API endpoint not found' });
|
|
737
|
-
return;
|
|
738
|
-
}
|
|
739
|
-
const { binding, params } = matched;
|
|
740
|
-
|
|
741
|
-
// Fail closed: a missing mask grants nothing (H1 / BFLA guard).
|
|
742
|
-
const permissions = ((req as AuthenticatedRequest).auth?.extra?.permissions as number | undefined) ?? 0;
|
|
743
|
-
if (!hasPermission(permissions, binding.permission)) {
|
|
744
|
-
logger.warn('REST API permission denied', {
|
|
745
|
-
method,
|
|
746
|
-
path: pathname,
|
|
747
|
-
required: getPermissionNames(binding.permission),
|
|
748
|
-
has: getPermissionNames(permissions),
|
|
749
|
-
});
|
|
750
|
-
sendJson(res, 403, {
|
|
751
|
-
error: 'Forbidden',
|
|
752
|
-
message: `This endpoint requires permission: ${getPermissionNames(binding.permission).join(', ')}`,
|
|
753
|
-
});
|
|
754
|
-
return;
|
|
755
|
-
}
|
|
756
|
-
|
|
757
|
-
if (binding.requires === 'ha' && !deps.haClient) {
|
|
758
|
-
sendJson(res, 503, { error: 'Home Assistant not configured' });
|
|
759
|
-
return;
|
|
760
|
-
}
|
|
761
|
-
if (binding.requires === 'omada' && !deps.omadaClient) {
|
|
762
|
-
sendJson(res, 503, { error: 'Omada not configured' });
|
|
763
|
-
return;
|
|
764
|
-
}
|
|
765
|
-
|
|
766
|
-
try {
|
|
767
|
-
const body = method === 'POST' ? await parseBody(req) : undefined;
|
|
768
|
-
const response = await binding.handle({
|
|
769
|
-
params,
|
|
770
|
-
query: parsedUrl.searchParams,
|
|
771
|
-
body,
|
|
772
|
-
permissions,
|
|
773
|
-
deps,
|
|
774
|
-
});
|
|
775
|
-
sendJson(res, response.status, response.body);
|
|
776
|
-
} catch (error) {
|
|
777
|
-
// M5: a body that exceeded the size cap surfaces as 413, not a generic 500.
|
|
778
|
-
const statusCode = (error as { statusCode?: number } | undefined)?.statusCode;
|
|
779
|
-
if (statusCode === 413) {
|
|
780
|
-
if (!res.headersSent) {
|
|
781
|
-
sendJson(res, 413, { error: 'Payload Too Large', message: 'Request body exceeds the size limit' });
|
|
782
|
-
}
|
|
783
|
-
return;
|
|
784
|
-
}
|
|
785
|
-
logger.error('REST API error', { error, url });
|
|
786
|
-
// M6: log the detail above; never echo raw error text to the client.
|
|
787
|
-
sendJson(res, 500, { error: 'Internal server error', message: sanitizeError(error) });
|
|
788
|
-
}
|
|
789
|
-
}
|