@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/chatSlice.ts
DELETED
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* MCP_CHAT_TOOLS slice parsing — standalone module with zero imports so both
|
|
3
|
-
* config.ts and the chat-face binding registry can use it without import cycles.
|
|
4
|
-
*
|
|
5
|
-
* Syntax (operator UX modeled on category:permission slicing):
|
|
6
|
-
* MCP_CHAT_TOOLS=ha-core:rw,ha-history:r,omada-read:r
|
|
7
|
-
*
|
|
8
|
-
* Unset -> undefined -> the chat face serves exactly the legacy 8 tools
|
|
9
|
-
* (pre-v1.7 behavior). Set -> the slice governs which chat-ELIGIBLE bindings
|
|
10
|
-
* are active; it can only narrow what the code marks eligible, never widen.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
/** Chat-face tool categories. Deliberately few — this is a curated surface. */
|
|
14
|
-
export const CHAT_CATEGORIES = [
|
|
15
|
-
'ha-core', // states, search, summaries, version + callService (write)
|
|
16
|
-
'ha-history', // entity history
|
|
17
|
-
'ha-automations', // list automations
|
|
18
|
-
'omada-read', // omada_browse / omada_read (resource graph, read-only)
|
|
19
|
-
'omada-write', // reversible network writes (SSID on/off)
|
|
20
|
-
] as const;
|
|
21
|
-
|
|
22
|
-
export type ChatCategory = (typeof CHAT_CATEGORIES)[number];
|
|
23
|
-
export type ChatAccess = 'read' | 'write';
|
|
24
|
-
|
|
25
|
-
/** Operator-selected slice: category -> allowed access levels. */
|
|
26
|
-
export type ChatToolsSlice = Map<ChatCategory, Set<ChatAccess>>;
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Parse `MCP_CHAT_TOOLS`. Returns `undefined` when unset/blank (legacy default).
|
|
30
|
-
* Throws on malformed input so a config typo fails loudly at startup (same
|
|
31
|
-
* philosophy as parseRestActions).
|
|
32
|
-
*/
|
|
33
|
-
export function parseChatTools(raw: string | undefined): ChatToolsSlice | undefined {
|
|
34
|
-
if (!raw || raw.trim() === '') {
|
|
35
|
-
return undefined;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const slice: ChatToolsSlice = new Map();
|
|
39
|
-
for (const part of raw.split(',').map((p) => p.trim()).filter(Boolean)) {
|
|
40
|
-
const [name, access] = part.split(':').map((p) => p.trim());
|
|
41
|
-
if (!(CHAT_CATEGORIES as readonly string[]).includes(name)) {
|
|
42
|
-
throw new Error(
|
|
43
|
-
`Invalid MCP_CHAT_TOOLS category '${name}'. Known categories: ${CHAT_CATEGORIES.join(', ')}`
|
|
44
|
-
);
|
|
45
|
-
}
|
|
46
|
-
if (!access || !/^(r|w|rw)$/.test(access)) {
|
|
47
|
-
throw new Error(
|
|
48
|
-
`Invalid MCP_CHAT_TOOLS access '${access ?? ''}' for '${name}': use r, w, or rw (e.g. "${name}:r")`
|
|
49
|
-
);
|
|
50
|
-
}
|
|
51
|
-
const levels = slice.get(name as ChatCategory) ?? new Set<ChatAccess>();
|
|
52
|
-
if (access.includes('r')) levels.add('read');
|
|
53
|
-
if (access.includes('w')) levels.add('write');
|
|
54
|
-
slice.set(name as ChatCategory, levels);
|
|
55
|
-
}
|
|
56
|
-
return slice;
|
|
57
|
-
}
|
package/src/server/common.ts
DELETED
|
@@ -1,170 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Common MCP server creation
|
|
3
|
-
* Shared across all transport types (stdio, SSE, stream)
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { McpServer } from '@modelcontextprotocol/server';
|
|
7
|
-
import type { RegisteredTool } from '@modelcontextprotocol/server';
|
|
8
|
-
import type { HaClient } from '../haClient/index.js';
|
|
9
|
-
import type { LocalAIClient } from '../localAI/index.js';
|
|
10
|
-
import type { OmadaClient } from '../omadaClient/index.js';
|
|
11
|
-
import { registerAllTools } from '../tools/index.js';
|
|
12
|
-
import { isToolVisibleTo } from '../tools/common.js';
|
|
13
|
-
import type { OmadaRegistrationMode } from '../tools/omada/index.js';
|
|
14
|
-
import type { RestAction } from '../tools/infra/index.js';
|
|
15
|
-
import { registerAllResources } from '../resources/index.js';
|
|
16
|
-
import { generateInstructions } from './instructions.js';
|
|
17
|
-
import { logger } from '../utils/logger.js';
|
|
18
|
-
import type { OwnerQuestionService } from '../ownerQuestions/service.js';
|
|
19
|
-
import { VERSION } from '../version.js';
|
|
20
|
-
|
|
21
|
-
export interface CreateServerOptions {
|
|
22
|
-
haClient?: HaClient;
|
|
23
|
-
omadaClient?: OmadaClient;
|
|
24
|
-
aiClient?: LocalAIClient;
|
|
25
|
-
/** Pre-registered REST actions for invokeAction (empty/undefined = tool not registered). */
|
|
26
|
-
restActions?: Record<string, RestAction>;
|
|
27
|
-
/** Tool registration strategy for the Omada plugin (default 'eager'). */
|
|
28
|
-
toolRegistrationMode?: OmadaRegistrationMode;
|
|
29
|
-
/**
|
|
30
|
-
* The caller's permission mask. When set, tools whose static permission the
|
|
31
|
-
* caller lacks are disabled, which hides them from `tools/list` (and the SDK
|
|
32
|
-
* rejects calls to them). Undefined = no filtering (stdio local trust).
|
|
33
|
-
* Execution-time RBAC in wrapToolHandler still applies either way.
|
|
34
|
-
*/
|
|
35
|
-
callerPermissions?: number;
|
|
36
|
-
/** Owner questions service (askOwner & co.), when running. */
|
|
37
|
-
ownerQuestions?: OwnerQuestionService;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export function createServer(options: CreateServerOptions): McpServer {
|
|
41
|
-
const { haClient, omadaClient, aiClient, restActions, toolRegistrationMode, callerPermissions, ownerQuestions } = options;
|
|
42
|
-
logger.debug('Creating MCP server instance');
|
|
43
|
-
|
|
44
|
-
// Generate instructions based on enabled plugins
|
|
45
|
-
const instructions = generateInstructions({
|
|
46
|
-
haEnabled: !!haClient,
|
|
47
|
-
omadaEnabled: !!omadaClient,
|
|
48
|
-
aiEnabled: !!aiClient,
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
const server = new McpServer(
|
|
52
|
-
{
|
|
53
|
-
name: 'mcp-ha-connect',
|
|
54
|
-
version: VERSION,
|
|
55
|
-
},
|
|
56
|
-
{
|
|
57
|
-
instructions,
|
|
58
|
-
}
|
|
59
|
-
);
|
|
60
|
-
|
|
61
|
-
logger.debug('Server instructions generated', { length: instructions.length });
|
|
62
|
-
|
|
63
|
-
// Register all tools based on configured clients, capturing each RegisteredTool
|
|
64
|
-
// handle so tools the caller can never use can be hidden afterwards.
|
|
65
|
-
const registeredTools = new Map<string, RegisteredTool>();
|
|
66
|
-
const originalRegisterTool = server.registerTool;
|
|
67
|
-
server.registerTool = ((...args: Parameters<McpServer['registerTool']>) => {
|
|
68
|
-
const config = args[1] as { inputSchema?: unknown; outputSchema?: unknown } | undefined;
|
|
69
|
-
memoizeJsonSchema(config?.inputSchema);
|
|
70
|
-
memoizeJsonSchema(config?.outputSchema);
|
|
71
|
-
const tool = (originalRegisterTool as (...a: unknown[]) => RegisteredTool).apply(server, args);
|
|
72
|
-
registeredTools.set(args[0], tool);
|
|
73
|
-
return tool;
|
|
74
|
-
}) as McpServer['registerTool'];
|
|
75
|
-
try {
|
|
76
|
-
registerAllTools({
|
|
77
|
-
server,
|
|
78
|
-
haClient,
|
|
79
|
-
omadaClient,
|
|
80
|
-
aiClient,
|
|
81
|
-
restActions,
|
|
82
|
-
toolRegistrationMode,
|
|
83
|
-
ownerQuestions,
|
|
84
|
-
});
|
|
85
|
-
} finally {
|
|
86
|
-
server.registerTool = originalRegisterTool;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
if (callerPermissions !== undefined) {
|
|
90
|
-
const hidden = hideUnpermittedTools(registeredTools, callerPermissions);
|
|
91
|
-
logger.debug('Filtered tools by caller permissions', { visible: registeredTools.size - hidden, hidden });
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// Register Home Assistant resources (if HA client provided)
|
|
95
|
-
if (haClient) {
|
|
96
|
-
registerAllResources(server, haClient);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
logger.debug('MCP server instance created');
|
|
100
|
-
|
|
101
|
-
return server;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* Disable every tool the caller cannot use (it lacks the tool's static permission, or the
|
|
106
|
-
* tool's visibility rule — e.g. omada_read's per-path one — finds nothing usable). Disabled
|
|
107
|
-
* tools are omitted from `tools/list`, so an LLM client only plans with tools it can use.
|
|
108
|
-
* Returns the number of tools hidden.
|
|
109
|
-
*/
|
|
110
|
-
export function hideUnpermittedTools(tools: Map<string, RegisteredTool>, callerPermissions: number): number {
|
|
111
|
-
let hidden = 0;
|
|
112
|
-
for (const [name, tool] of tools) {
|
|
113
|
-
if (!isToolVisibleTo(name, callerPermissions)) {
|
|
114
|
-
tool.disable();
|
|
115
|
-
hidden++;
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
return hidden;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/** Schemas whose JSON Schema conversion is already memoized (process lifetime). */
|
|
122
|
-
const memoizedSchemas = new WeakSet<object>();
|
|
123
|
-
|
|
124
|
-
type JsonSchemaConverter = (options?: unknown) => unknown;
|
|
125
|
-
|
|
126
|
-
/** Whether `schema`'s JSON Schema conversion has been memoized (for tests/diagnostics). */
|
|
127
|
-
export function isJsonSchemaMemoized(schema: unknown): boolean {
|
|
128
|
-
return schema !== null && typeof schema === 'object' && memoizedSchemas.has(schema);
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* Convert a tool schema to JSON Schema once per process instead of once per request.
|
|
133
|
-
*
|
|
134
|
-
* MCP SDK v2 converts every tool's schema to JSON Schema eagerly inside
|
|
135
|
-
* `registerTool`, through the schema's Standard Schema `~standard.jsonSchema`
|
|
136
|
-
* provider. The server is stateless and registers every tool on every request, so
|
|
137
|
-
* that conversion (zod's `toJSONSchema`) became about half of request setup time. Tool
|
|
138
|
-
* schemas are module-level constants, so the result never changes: this shadows the
|
|
139
|
-
* provider on the schema instance with a cached one (the SDK's documented hook for a
|
|
140
|
-
* custom provider). Each call returns a deep clone, so a caller that mutates the
|
|
141
|
-
* result can never corrupt the cache. Validation (`~standard.validate`) is untouched.
|
|
142
|
-
*/
|
|
143
|
-
export function memoizeJsonSchema(schema: unknown): void {
|
|
144
|
-
if (schema === null || typeof schema !== 'object' || memoizedSchemas.has(schema)) {
|
|
145
|
-
return;
|
|
146
|
-
}
|
|
147
|
-
memoizedSchemas.add(schema);
|
|
148
|
-
const std = (schema as { '~standard'?: { jsonSchema?: { input?: JsonSchemaConverter; output?: JsonSchemaConverter } } })[
|
|
149
|
-
'~standard'
|
|
150
|
-
];
|
|
151
|
-
if (!std?.jsonSchema?.input || !std.jsonSchema.output) {
|
|
152
|
-
return; // Not a converter-carrying Standard Schema: leave it to the SDK as-is.
|
|
153
|
-
}
|
|
154
|
-
const memo = (convert: JsonSchemaConverter): JsonSchemaConverter => {
|
|
155
|
-
const cache = new Map<string, unknown>();
|
|
156
|
-
return (options) => {
|
|
157
|
-
const key = JSON.stringify(options ?? null);
|
|
158
|
-
if (!cache.has(key)) {
|
|
159
|
-
cache.set(key, convert(options));
|
|
160
|
-
}
|
|
161
|
-
return structuredClone(cache.get(key));
|
|
162
|
-
};
|
|
163
|
-
};
|
|
164
|
-
Object.defineProperty(schema, '~standard', {
|
|
165
|
-
value: { ...std, jsonSchema: { input: memo(std.jsonSchema.input), output: memo(std.jsonSchema.output) } },
|
|
166
|
-
configurable: true,
|
|
167
|
-
enumerable: false,
|
|
168
|
-
writable: false,
|
|
169
|
-
});
|
|
170
|
-
}
|
|
@@ -1,343 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* SSE Event Subscription Endpoint
|
|
3
|
-
* Allows HTTP clients to subscribe to Home Assistant events via Server-Sent Events
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
7
|
-
import { randomUUID } from 'node:crypto';
|
|
8
|
-
import type { EventSubscriber, HaEvent } from '../haClient/events.js';
|
|
9
|
-
import { logger } from '../utils/logger.js';
|
|
10
|
-
import { sanitizeError } from '../utils/sanitizeError.js';
|
|
11
|
-
import { Permission, hasPermission } from '../permissions/index.js';
|
|
12
|
-
import type { AuthenticatedRequest } from './auth.js';
|
|
13
|
-
|
|
14
|
-
export interface SSEClient {
|
|
15
|
-
id: string;
|
|
16
|
-
res: ServerResponse;
|
|
17
|
-
subscriptionIds: string[];
|
|
18
|
-
domain?: string;
|
|
19
|
-
entityId?: string;
|
|
20
|
-
eventTypes?: string[];
|
|
21
|
-
createdAt: Date;
|
|
22
|
-
lastEventAt?: Date;
|
|
23
|
-
eventCount: number;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
// Store active SSE clients
|
|
27
|
-
const clients = new Map<string, SSEClient>();
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Generate an unguessable SSE client ID (L5).
|
|
31
|
-
*
|
|
32
|
-
* Uses a CSPRNG (randomUUID) rather than Math.random()/Date.now(), so a client
|
|
33
|
-
* ID cannot be predicted or enumerated by an observer who knows roughly when a
|
|
34
|
-
* connection was opened.
|
|
35
|
-
*/
|
|
36
|
-
export function generateClientId(): string {
|
|
37
|
-
return randomUUID();
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Parse query parameters from URL
|
|
42
|
-
*/
|
|
43
|
-
function parseQueryParams(url: string): URLSearchParams {
|
|
44
|
-
const queryIndex = url.indexOf('?');
|
|
45
|
-
if (queryIndex === -1) {
|
|
46
|
-
return new URLSearchParams();
|
|
47
|
-
}
|
|
48
|
-
return new URLSearchParams(url.slice(queryIndex + 1));
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Send SSE event to a client
|
|
53
|
-
*/
|
|
54
|
-
function sendSSEEvent(res: ServerResponse, event: string, data: unknown): void {
|
|
55
|
-
if (res.writableEnded) {
|
|
56
|
-
return;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
const payload = typeof data === 'string' ? data : JSON.stringify(data);
|
|
60
|
-
res.write(`event: ${event}\n`);
|
|
61
|
-
res.write(`data: ${payload}\n\n`);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Send SSE comment (keep-alive)
|
|
66
|
-
*/
|
|
67
|
-
function sendSSEComment(res: ServerResponse, comment: string): void {
|
|
68
|
-
if (res.writableEnded) {
|
|
69
|
-
return;
|
|
70
|
-
}
|
|
71
|
-
res.write(`: ${comment}\n\n`);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Handle SSE subscription request
|
|
76
|
-
*/
|
|
77
|
-
export async function handleEventSubscription(
|
|
78
|
-
req: IncomingMessage,
|
|
79
|
-
res: ServerResponse,
|
|
80
|
-
eventSubscriber: EventSubscriber
|
|
81
|
-
): Promise<void> {
|
|
82
|
-
// L2: the SSE stream reads entity state — require QUERY, like every other read.
|
|
83
|
-
// Auth middleware has already populated req.auth; fail closed if the mask is absent.
|
|
84
|
-
const userPermissions = ((req as AuthenticatedRequest).auth?.extra?.permissions as number | undefined) ?? 0;
|
|
85
|
-
if (!hasPermission(userPermissions, Permission.QUERY)) {
|
|
86
|
-
logger.warn('SSE subscription denied: missing QUERY permission');
|
|
87
|
-
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
88
|
-
res.end(JSON.stringify({ error: 'Forbidden', message: 'SSE event subscription requires QUERY permission' }));
|
|
89
|
-
return;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
const clientId = generateClientId();
|
|
93
|
-
const params = parseQueryParams(req.url ?? '');
|
|
94
|
-
|
|
95
|
-
// Parse filter parameters
|
|
96
|
-
const domain = params.get('domain') ?? undefined;
|
|
97
|
-
const entityId = params.get('entity_id') ?? undefined;
|
|
98
|
-
const eventTypesParam = params.get('event_types');
|
|
99
|
-
const eventTypes = eventTypesParam ? eventTypesParam.split(',').map(t => t.trim()) : undefined;
|
|
100
|
-
|
|
101
|
-
logger.info('New SSE subscription request', {
|
|
102
|
-
clientId,
|
|
103
|
-
domain,
|
|
104
|
-
entityId,
|
|
105
|
-
eventTypes,
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
// Set SSE headers
|
|
109
|
-
res.writeHead(200, {
|
|
110
|
-
'Content-Type': 'text/event-stream',
|
|
111
|
-
'Cache-Control': 'no-cache',
|
|
112
|
-
'Connection': 'keep-alive',
|
|
113
|
-
'Access-Control-Allow-Origin': req.headers.origin ?? '*',
|
|
114
|
-
'Access-Control-Allow-Credentials': 'true',
|
|
115
|
-
'X-Accel-Buffering': 'no', // Disable nginx buffering
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
// Create client record
|
|
119
|
-
const client: SSEClient = {
|
|
120
|
-
id: clientId,
|
|
121
|
-
res,
|
|
122
|
-
subscriptionIds: [],
|
|
123
|
-
domain,
|
|
124
|
-
entityId,
|
|
125
|
-
eventTypes,
|
|
126
|
-
createdAt: new Date(),
|
|
127
|
-
eventCount: 0,
|
|
128
|
-
};
|
|
129
|
-
clients.set(clientId, client);
|
|
130
|
-
|
|
131
|
-
// Send initial connection event
|
|
132
|
-
sendSSEEvent(res, 'connected', {
|
|
133
|
-
client_id: clientId,
|
|
134
|
-
message: 'Connected to Home Assistant event stream',
|
|
135
|
-
filters: { domain, entityId, eventTypes },
|
|
136
|
-
timestamp: new Date().toISOString(),
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
// Ensure event subscriber is connected
|
|
140
|
-
if (!eventSubscriber.isConnected()) {
|
|
141
|
-
try {
|
|
142
|
-
await eventSubscriber.connect();
|
|
143
|
-
} catch (error) {
|
|
144
|
-
logger.error('Failed to connect event subscriber', { error });
|
|
145
|
-
// M6: log detail above; the SSE client gets a generic message only.
|
|
146
|
-
sendSSEEvent(res, 'error', {
|
|
147
|
-
error: 'Failed to connect to Home Assistant',
|
|
148
|
-
message: sanitizeError(error),
|
|
149
|
-
});
|
|
150
|
-
res.end();
|
|
151
|
-
clients.delete(clientId);
|
|
152
|
-
return;
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
// Create event callback
|
|
157
|
-
const eventCallback = (event: HaEvent) => {
|
|
158
|
-
// Apply filters
|
|
159
|
-
if (eventTypes && !eventTypes.includes(event.event_type)) {
|
|
160
|
-
return;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
if (domain && event.event_type === 'state_changed') {
|
|
164
|
-
const eventDomain = event.data.entity_id?.split('.')[0];
|
|
165
|
-
if (eventDomain !== domain) {
|
|
166
|
-
return;
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
if (entityId && event.data.entity_id !== entityId) {
|
|
171
|
-
return;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
// Update client stats
|
|
175
|
-
client.lastEventAt = new Date();
|
|
176
|
-
client.eventCount++;
|
|
177
|
-
|
|
178
|
-
// Send event to client
|
|
179
|
-
sendSSEEvent(res, event.event_type, {
|
|
180
|
-
event_type: event.event_type,
|
|
181
|
-
entity_id: event.data.entity_id,
|
|
182
|
-
old_state: event.data.old_state,
|
|
183
|
-
new_state: event.data.new_state,
|
|
184
|
-
domain: event.data.domain,
|
|
185
|
-
service: event.data.service,
|
|
186
|
-
service_data: event.data.service_data,
|
|
187
|
-
time_fired: event.time_fired,
|
|
188
|
-
context: event.context,
|
|
189
|
-
});
|
|
190
|
-
};
|
|
191
|
-
|
|
192
|
-
// Subscribe to events based on filters
|
|
193
|
-
try {
|
|
194
|
-
let subscriptionId: string;
|
|
195
|
-
|
|
196
|
-
if (entityId) {
|
|
197
|
-
// Subscribe to specific entity
|
|
198
|
-
subscriptionId = await eventSubscriber.subscribeEntity(entityId, eventCallback);
|
|
199
|
-
} else if (domain) {
|
|
200
|
-
// Subscribe to domain
|
|
201
|
-
subscriptionId = await eventSubscriber.subscribeDomain(domain, eventCallback);
|
|
202
|
-
} else if (eventTypes && eventTypes.length === 1) {
|
|
203
|
-
// Subscribe to specific event type
|
|
204
|
-
subscriptionId = await eventSubscriber.subscribeEventType(eventTypes[0], eventCallback);
|
|
205
|
-
} else {
|
|
206
|
-
// Subscribe to all state_changed events by default
|
|
207
|
-
subscriptionId = await eventSubscriber.subscribeEventType('state_changed', eventCallback);
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
client.subscriptionIds.push(subscriptionId);
|
|
211
|
-
|
|
212
|
-
logger.info('SSE client subscribed to events', {
|
|
213
|
-
clientId,
|
|
214
|
-
subscriptionId,
|
|
215
|
-
});
|
|
216
|
-
} catch (error) {
|
|
217
|
-
logger.error('Failed to subscribe to events', { error, clientId });
|
|
218
|
-
// M6: log detail above; the SSE client gets a generic message only.
|
|
219
|
-
sendSSEEvent(res, 'error', {
|
|
220
|
-
error: 'Failed to subscribe to events',
|
|
221
|
-
message: sanitizeError(error),
|
|
222
|
-
});
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
// Keep-alive ping every 30 seconds
|
|
226
|
-
const keepAliveInterval = setInterval(() => {
|
|
227
|
-
if (res.writableEnded) {
|
|
228
|
-
clearInterval(keepAliveInterval);
|
|
229
|
-
return;
|
|
230
|
-
}
|
|
231
|
-
sendSSEComment(res, `keepalive ${new Date().toISOString()}`);
|
|
232
|
-
}, 30000);
|
|
233
|
-
|
|
234
|
-
// Handle client disconnect
|
|
235
|
-
req.on('close', () => {
|
|
236
|
-
clearInterval(keepAliveInterval);
|
|
237
|
-
|
|
238
|
-
// Unsubscribe from all events
|
|
239
|
-
for (const subId of client.subscriptionIds) {
|
|
240
|
-
eventSubscriber.unsubscribe(subId).catch(error => {
|
|
241
|
-
logger.error('Failed to unsubscribe', { error, subscriptionId: subId });
|
|
242
|
-
});
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
clients.delete(clientId);
|
|
246
|
-
|
|
247
|
-
logger.info('SSE client disconnected', {
|
|
248
|
-
clientId,
|
|
249
|
-
eventCount: client.eventCount,
|
|
250
|
-
duration: Date.now() - client.createdAt.getTime(),
|
|
251
|
-
});
|
|
252
|
-
});
|
|
253
|
-
|
|
254
|
-
// Handle errors
|
|
255
|
-
req.on('error', (error) => {
|
|
256
|
-
logger.error('SSE request error', { error, clientId });
|
|
257
|
-
clearInterval(keepAliveInterval);
|
|
258
|
-
clients.delete(clientId);
|
|
259
|
-
});
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
/**
|
|
263
|
-
* Get list of active SSE clients
|
|
264
|
-
*/
|
|
265
|
-
export function getActiveClients(): Array<{
|
|
266
|
-
id: string;
|
|
267
|
-
domain?: string;
|
|
268
|
-
entityId?: string;
|
|
269
|
-
eventTypes?: string[];
|
|
270
|
-
createdAt: Date;
|
|
271
|
-
lastEventAt?: Date;
|
|
272
|
-
eventCount: number;
|
|
273
|
-
}> {
|
|
274
|
-
return Array.from(clients.values()).map(client => ({
|
|
275
|
-
id: client.id,
|
|
276
|
-
domain: client.domain,
|
|
277
|
-
entityId: client.entityId,
|
|
278
|
-
eventTypes: client.eventTypes,
|
|
279
|
-
createdAt: client.createdAt,
|
|
280
|
-
lastEventAt: client.lastEventAt,
|
|
281
|
-
eventCount: client.eventCount,
|
|
282
|
-
}));
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
/**
|
|
286
|
-
* Get count of active SSE clients
|
|
287
|
-
*/
|
|
288
|
-
export function getClientCount(): number {
|
|
289
|
-
return clients.size;
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
/**
|
|
293
|
-
* Broadcast event to all matching clients
|
|
294
|
-
*/
|
|
295
|
-
export function broadcastEvent(event: string, data: unknown, filter?: {
|
|
296
|
-
domain?: string;
|
|
297
|
-
entityId?: string;
|
|
298
|
-
}): void {
|
|
299
|
-
for (const client of clients.values()) {
|
|
300
|
-
// Apply filter
|
|
301
|
-
if (filter?.domain && client.domain !== filter.domain) {
|
|
302
|
-
continue;
|
|
303
|
-
}
|
|
304
|
-
if (filter?.entityId && client.entityId !== filter.entityId) {
|
|
305
|
-
continue;
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
sendSSEEvent(client.res, event, data);
|
|
309
|
-
client.eventCount++;
|
|
310
|
-
client.lastEventAt = new Date();
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
/**
|
|
315
|
-
* Disconnect a specific client
|
|
316
|
-
*/
|
|
317
|
-
export function disconnectClient(clientId: string): boolean {
|
|
318
|
-
const client = clients.get(clientId);
|
|
319
|
-
if (!client) {
|
|
320
|
-
return false;
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
sendSSEEvent(client.res, 'disconnect', { reason: 'Server initiated disconnect' });
|
|
324
|
-
client.res.end();
|
|
325
|
-
clients.delete(clientId);
|
|
326
|
-
|
|
327
|
-
return true;
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
/**
|
|
331
|
-
* Disconnect all clients
|
|
332
|
-
*/
|
|
333
|
-
export function disconnectAllClients(): number {
|
|
334
|
-
const count = clients.size;
|
|
335
|
-
|
|
336
|
-
for (const client of clients.values()) {
|
|
337
|
-
sendSSEEvent(client.res, 'disconnect', { reason: 'Server shutdown' });
|
|
338
|
-
client.res.end();
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
clients.clear();
|
|
342
|
-
return count;
|
|
343
|
-
}
|