@coffeerunhobby/mcp-ha-connect 1.7.4 → 1.7.6
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/.codex/review-rules.md +16 -0
- package/.github/workflows/ci.yml +6 -6
- package/AGENTS.md +8 -15
- package/README.md +9 -1
- package/dist/permissions/index.d.ts +8 -0
- package/dist/permissions/index.js +15 -0
- package/dist/permissions/index.js.map +1 -1
- package/dist/server/auth.js +10 -1
- package/dist/server/auth.js.map +1 -1
- package/dist/server/common.d.ts +15 -1
- package/dist/server/common.js +43 -9
- package/dist/server/common.js.map +1 -1
- package/dist/server/stream.d.ts +1 -1
- package/dist/server/stream.js +6 -2
- package/dist/server/stream.js.map +1 -1
- package/dist/tools/common.d.ts +10 -0
- package/dist/tools/common.js +37 -0
- package/dist/tools/common.js.map +1 -1
- package/dist/tools/homeassistant/controlClimate.js +4 -0
- package/dist/tools/homeassistant/controlClimate.js.map +1 -1
- package/dist/tools/homeassistant/controlCover.js +4 -0
- package/dist/tools/homeassistant/controlCover.js.map +1 -1
- package/dist/tools/homeassistant/controlFan.js +4 -0
- package/dist/tools/homeassistant/controlFan.js.map +1 -1
- package/dist/tools/homeassistant/controlLight.js +4 -0
- package/dist/tools/homeassistant/controlLight.js.map +1 -1
- package/dist/tools/homeassistant/controlMediaPlayer.js +4 -0
- package/dist/tools/homeassistant/controlMediaPlayer.js.map +1 -1
- package/dist/tools/homeassistant/entityAction.js +4 -0
- package/dist/tools/homeassistant/entityAction.js.map +1 -1
- package/dist/tools/homeassistant/entityGuard.d.ts +15 -0
- package/dist/tools/homeassistant/entityGuard.js +25 -0
- package/dist/tools/homeassistant/entityGuard.js.map +1 -0
- package/dist/tools/homeassistant/sceneAndScript.js +7 -0
- package/dist/tools/homeassistant/sceneAndScript.js.map +1 -1
- package/dist/tools/omada/graph.d.ts +6 -0
- package/dist/tools/omada/graph.js +11 -2
- package/dist/tools/omada/graph.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/docs/CHANGELOG.md +14 -0
- package/openapi.json +1 -1
- package/package.json +1 -1
- package/scripts/generate-jwt.ts +8 -9
- package/src/permissions/index.ts +18 -0
- package/src/server/auth.ts +11 -1
- package/src/server/common.ts +53 -11
- package/src/server/stream.ts +7 -2
- package/src/tools/common.ts +42 -0
- package/src/tools/homeassistant/controlClimate.ts +3 -0
- package/src/tools/homeassistant/controlCover.ts +3 -0
- package/src/tools/homeassistant/controlFan.ts +3 -0
- package/src/tools/homeassistant/controlLight.ts +3 -0
- package/src/tools/homeassistant/controlMediaPlayer.ts +3 -0
- package/src/tools/homeassistant/entityAction.ts +3 -0
- package/src/tools/homeassistant/entityGuard.ts +28 -0
- package/src/tools/homeassistant/sceneAndScript.ts +5 -0
- package/src/tools/omada/graph.ts +12 -2
- package/src/version.ts +1 -1
- package/tests/permissions/index.test.ts +23 -0
- package/tests/scripts/generateJwt.test.ts +44 -0
- package/tests/server/auth.test.ts +25 -0
- package/tests/server/toolVisibility.http.test.ts +111 -0
- package/tests/server/toolVisibility.test.ts +130 -0
- package/tests/tools/homeassistant/handlers.test.ts +41 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"controlFan.js","sourceRoot":"","sources":["../../../src/tools/homeassistant/controlFan.ts"],"names":[],"mappings":"AAAA;;GAEG;AAIH,OAAO,EAAE,gBAAgB,EAAE,YAAY,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"controlFan.js","sourceRoot":"","sources":["../../../src/tools/homeassistant/controlFan.ts"],"names":[],"mappings":"AAAA;;GAEG;AAIH,OAAO,EAAE,gBAAgB,EAAE,YAAY,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAC3F,OAAO,EAAE,oBAAoB,EAAE,MAAM,kBAAkB,CAAC;AAKxD,MAAM,UAAU,sBAAsB,CAAC,MAAiB,EAAE,MAAgB;IACxE,MAAM,CAAC,YAAY,CACjB,YAAY,EACZ;QACE,WAAW,EAAE,gEAAgE;QAC7E,WAAW,EAAE,gBAAgB,CAAC,KAAK;KACpC,EACD,eAAe,CAAC,YAAY,EAAE,KAAK,EAAE,IAAoB,EAAE,EAAE;QAC3D,MAAM,OAAO,GAAG,MAAM,oBAAoB,CAAC,MAAM,EAAE,IAAI,CAAC,SAAS,CAAC,CAAC;QACnE,IAAI,OAAO;YAAE,OAAO,OAAO,CAAC;QAC5B,IAAI,OAAO,GAAW,IAAI,CAAC,MAAM,CAAC;QAClC,MAAM,WAAW,GAA4B,EAAE,CAAC;QAEhD,IAAI,IAAI,CAAC,MAAM,KAAK,WAAW,EAAE,CAAC;YAChC,OAAO,GAAG,gBAAgB,CAAC;YAC3B,IAAI,IAAI,CAAC,UAAU,KAAK,SAAS;gBAAE,WAAW,CAAC,UAAU,GAAG,IAAI,CAAC,UAAU,CAAC;QAC9E,CAAC;aAAM,IAAI,IAAI,CAAC,MAAM,KAAK,WAAW,EAAE,CAAC;YACvC,IAAI,IAAI,CAAC,WAAW,KAAK,SAAS;gBAAE,WAAW,CAAC,WAAW,GAAG,IAAI,CAAC,WAAW,CAAC;QACjF,CAAC;aAAM,IAAI,IAAI,CAAC,MAAM,KAAK,eAAe,EAAE,CAAC;YAC3C,IAAI,IAAI,CAAC,SAAS,KAAK,SAAS;gBAAE,WAAW,CAAC,SAAS,GAAG,IAAI,CAAC,SAAS,CAAC;QAC3E,CAAC;QAED,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,WAAW,CAAC;YACtC,MAAM,EAAE,KAAK;YACb,OAAO;YACP,MAAM,EAAE,EAAE,SAAS,EAAE,IAAI,CAAC,SAAS,EAAE;YACrC,YAAY,EAAE,MAAM,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,SAAS;SAC5E,CAAC,CAAC;QACH,OAAO,YAAY,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,CAAC,MAAM,EAAE,OAAO,EAAE,MAAM,CAAC,OAAO,EAAE,CAAC,CAAC;IAClH,CAAC,EAAE,UAAU,CAAC,OAAO,CAAC,CACvB,CAAC;AACJ,CAAC"}
|
|
@@ -2,11 +2,15 @@
|
|
|
2
2
|
* controlLight tool - Control lights with advanced options
|
|
3
3
|
*/
|
|
4
4
|
import { controlLightSchema, toToolResult, wrapToolHandler, Permission } from '../common.js';
|
|
5
|
+
import { entityNotFoundResult } from './entityGuard.js';
|
|
5
6
|
export function registerControlLightTool(server, client) {
|
|
6
7
|
server.registerTool('controlLight', {
|
|
7
8
|
description: 'Control a light with advanced options like brightness, color, and color temperature.',
|
|
8
9
|
inputSchema: controlLightSchema.shape,
|
|
9
10
|
}, wrapToolHandler('controlLight', async (args) => {
|
|
11
|
+
const missing = await entityNotFoundResult(client, args.entity_id);
|
|
12
|
+
if (missing)
|
|
13
|
+
return missing;
|
|
10
14
|
const serviceData = {};
|
|
11
15
|
if (args.brightness_pct !== undefined)
|
|
12
16
|
serviceData.brightness_pct = args.brightness_pct;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"controlLight.js","sourceRoot":"","sources":["../../../src/tools/homeassistant/controlLight.ts"],"names":[],"mappings":"AAAA;;GAEG;AAIH,OAAO,EAAE,kBAAkB,EAAE,YAAY,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"controlLight.js","sourceRoot":"","sources":["../../../src/tools/homeassistant/controlLight.ts"],"names":[],"mappings":"AAAA;;GAEG;AAIH,OAAO,EAAE,kBAAkB,EAAE,YAAY,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAC7F,OAAO,EAAE,oBAAoB,EAAE,MAAM,kBAAkB,CAAC;AAKxD,MAAM,UAAU,wBAAwB,CAAC,MAAiB,EAAE,MAAgB;IAC1E,MAAM,CAAC,YAAY,CACjB,cAAc,EACd;QACE,WAAW,EAAE,sFAAsF;QACnG,WAAW,EAAE,kBAAkB,CAAC,KAAK;KACtC,EACD,eAAe,CAAC,cAAc,EAAE,KAAK,EAAE,IAAsB,EAAE,EAAE;QAC/D,MAAM,OAAO,GAAG,MAAM,oBAAoB,CAAC,MAAM,EAAE,IAAI,CAAC,SAAS,CAAC,CAAC;QACnE,IAAI,OAAO;YAAE,OAAO,OAAO,CAAC;QAC5B,MAAM,WAAW,GAA4B,EAAE,CAAC;QAChD,IAAI,IAAI,CAAC,cAAc,KAAK,SAAS;YAAE,WAAW,CAAC,cAAc,GAAG,IAAI,CAAC,cAAc,CAAC;QACxF,IAAI,IAAI,CAAC,iBAAiB,KAAK,SAAS;YAAE,WAAW,CAAC,iBAAiB,GAAG,IAAI,CAAC,iBAAiB,CAAC;QACjG,IAAI,IAAI,CAAC,SAAS,KAAK,SAAS;YAAE,WAAW,CAAC,SAAS,GAAG,IAAI,CAAC,SAAS,CAAC;QACzE,IAAI,IAAI,CAAC,UAAU,KAAK,SAAS;YAAE,WAAW,CAAC,UAAU,GAAG,IAAI,CAAC,UAAU,CAAC;QAE5E,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,WAAW,CAAC;YACtC,MAAM,EAAE,OAAO;YACf,OAAO,EAAE,IAAI,CAAC,MAAM;YACpB,MAAM,EAAE,EAAE,SAAS,EAAE,IAAI,CAAC,SAAS,EAAE;YACrC,YAAY,EAAE,MAAM,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,SAAS;SAC5E,CAAC,CAAC;QACH,OAAO,YAAY,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,CAAC,MAAM,EAAE,QAAQ,EAAE,WAAW,EAAE,OAAO,EAAE,MAAM,CAAC,OAAO,EAAE,CAAC,CAAC;IACzI,CAAC,EAAE,UAAU,CAAC,OAAO,CAAC,CACvB,CAAC;AACJ,CAAC"}
|
|
@@ -2,11 +2,15 @@
|
|
|
2
2
|
* controlMediaPlayer tool - Control media players
|
|
3
3
|
*/
|
|
4
4
|
import { controlMediaPlayerSchema, toToolResult, wrapToolHandler, Permission } from '../common.js';
|
|
5
|
+
import { entityNotFoundResult } from './entityGuard.js';
|
|
5
6
|
export function registerControlMediaPlayerTool(server, client) {
|
|
6
7
|
server.registerTool('controlMediaPlayer', {
|
|
7
8
|
description: 'Control a media player with playback, volume, and media selection.',
|
|
8
9
|
inputSchema: controlMediaPlayerSchema.shape,
|
|
9
10
|
}, wrapToolHandler('controlMediaPlayer', async (args) => {
|
|
11
|
+
const missing = await entityNotFoundResult(client, args.entity_id);
|
|
12
|
+
if (missing)
|
|
13
|
+
return missing;
|
|
10
14
|
const actionMap = {
|
|
11
15
|
play: 'media_play',
|
|
12
16
|
pause: 'media_pause',
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"controlMediaPlayer.js","sourceRoot":"","sources":["../../../src/tools/homeassistant/controlMediaPlayer.ts"],"names":[],"mappings":"AAAA;;GAEG;AAIH,OAAO,EAAE,wBAAwB,EAAE,YAAY,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"controlMediaPlayer.js","sourceRoot":"","sources":["../../../src/tools/homeassistant/controlMediaPlayer.ts"],"names":[],"mappings":"AAAA;;GAEG;AAIH,OAAO,EAAE,wBAAwB,EAAE,YAAY,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AACnG,OAAO,EAAE,oBAAoB,EAAE,MAAM,kBAAkB,CAAC;AAKxD,MAAM,UAAU,8BAA8B,CAAC,MAAiB,EAAE,MAAgB;IAChF,MAAM,CAAC,YAAY,CACjB,oBAAoB,EACpB;QACE,WAAW,EAAE,oEAAoE;QACjF,WAAW,EAAE,wBAAwB,CAAC,KAAK;KAC5C,EACD,eAAe,CAAC,oBAAoB,EAAE,KAAK,EAAE,IAA4B,EAAE,EAAE;QAC3E,MAAM,OAAO,GAAG,MAAM,oBAAoB,CAAC,MAAM,EAAE,IAAI,CAAC,SAAS,CAAC,CAAC;QACnE,IAAI,OAAO;YAAE,OAAO,OAAO,CAAC;QAC5B,MAAM,SAAS,GAA2B;YACxC,IAAI,EAAE,YAAY;YAClB,KAAK,EAAE,aAAa;YACpB,IAAI,EAAE,YAAY;YAClB,IAAI,EAAE,kBAAkB;YACxB,QAAQ,EAAE,sBAAsB;YAChC,UAAU,EAAE,YAAY;YACxB,WAAW,EAAE,aAAa;SAC3B,CAAC;QAEF,MAAM,WAAW,GAA4B,EAAE,CAAC;QAChD,IAAI,IAAI,CAAC,MAAM,KAAK,YAAY,IAAI,IAAI,CAAC,YAAY,KAAK,SAAS,EAAE,CAAC;YACpE,WAAW,CAAC,YAAY,GAAG,IAAI,CAAC,YAAY,CAAC;QAC/C,CAAC;QACD,IAAI,IAAI,CAAC,MAAM,KAAK,aAAa,IAAI,IAAI,CAAC,eAAe,KAAK,SAAS,EAAE,CAAC;YACxE,WAAW,CAAC,eAAe,GAAG,IAAI,CAAC,eAAe,CAAC;QACrD,CAAC;QAED,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,WAAW,CAAC;YACtC,MAAM,EAAE,cAAc;YACtB,OAAO,EAAE,SAAS,CAAC,IAAI,CAAC,MAAM,CAAC,IAAI,IAAI,CAAC,MAAM;YAC9C,MAAM,EAAE,EAAE,SAAS,EAAE,IAAI,CAAC,SAAS,EAAE;YACrC,YAAY,EAAE,MAAM,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,SAAS;SAC5E,CAAC,CAAC;QACH,OAAO,YAAY,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,CAAC,MAAM,EAAE,OAAO,EAAE,MAAM,CAAC,OAAO,EAAE,CAAC,CAAC;IAClH,CAAC,EAAE,UAAU,CAAC,OAAO,CAAC,CACvB,CAAC;AACJ,CAAC"}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* entityAction tool - Simple entity actions (turn_on, turn_off, toggle)
|
|
3
3
|
*/
|
|
4
4
|
import { entityActionSchema, toToolResult, wrapToolHandler, Permission } from '../common.js';
|
|
5
|
+
import { entityNotFoundResult } from './entityGuard.js';
|
|
5
6
|
export function registerEntityActionTool(server, client) {
|
|
6
7
|
server.registerTool('entityAction', {
|
|
7
8
|
description: 'Perform a simple action on an entity (turn_on, turn_off, toggle). Automatically detects the domain.',
|
|
@@ -11,6 +12,9 @@ export function registerEntityActionTool(server, client) {
|
|
|
11
12
|
if (!domain) {
|
|
12
13
|
return toToolResult({ error: 'Invalid entity_id format', hint: 'entity_id must be in domain.name format such as light.living_room — use the searchEntities tool to discover valid entity IDs' }, true);
|
|
13
14
|
}
|
|
15
|
+
const missing = await entityNotFoundResult(client, entity_id);
|
|
16
|
+
if (missing)
|
|
17
|
+
return missing;
|
|
14
18
|
const result = await client.callService({
|
|
15
19
|
domain,
|
|
16
20
|
service: action,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"entityAction.js","sourceRoot":"","sources":["../../../src/tools/homeassistant/entityAction.ts"],"names":[],"mappings":"AAAA;;GAEG;AAIH,OAAO,EAAE,kBAAkB,EAAE,YAAY,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"entityAction.js","sourceRoot":"","sources":["../../../src/tools/homeassistant/entityAction.ts"],"names":[],"mappings":"AAAA;;GAEG;AAIH,OAAO,EAAE,kBAAkB,EAAE,YAAY,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAC7F,OAAO,EAAE,oBAAoB,EAAE,MAAM,kBAAkB,CAAC;AAKxD,MAAM,UAAU,wBAAwB,CAAC,MAAiB,EAAE,MAAgB;IAC1E,MAAM,CAAC,YAAY,CACjB,cAAc,EACd;QACE,WAAW,EAAE,qGAAqG;QAClH,WAAW,EAAE,kBAAkB,CAAC,KAAK;KACtC,EACD,eAAe,CAAC,cAAc,EAAE,KAAK,EAAE,EAAE,SAAS,EAAE,MAAM,EAAoB,EAAE,EAAE;QAChF,MAAM,MAAM,GAAG,SAAS,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC;QACvC,IAAI,CAAC,MAAM,EAAE,CAAC;YACZ,OAAO,YAAY,CAAC,EAAE,KAAK,EAAE,0BAA0B,EAAE,IAAI,EAAE,8HAA8H,EAAE,EAAE,IAAI,CAAC,CAAC;QACzM,CAAC;QACD,MAAM,OAAO,GAAG,MAAM,oBAAoB,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC;QAC9D,IAAI,OAAO;YAAE,OAAO,OAAO,CAAC;QAC5B,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,WAAW,CAAC;YACtC,MAAM;YACN,OAAO,EAAE,MAAM;YACf,MAAM,EAAE,EAAE,SAAS,EAAE;SACtB,CAAC,CAAC;QACH,OAAO,YAAY,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,CAAC,OAAO,EAAE,CAAC,CAAC;IACrF,CAAC,EAAE,UAAU,CAAC,OAAO,CAAC,CACvB,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Existence guard for entity-targeted control tools.
|
|
3
|
+
*
|
|
4
|
+
* Home Assistant silently ignores service calls whose target entity_id does not
|
|
5
|
+
* exist: the call returns 200 with an empty change list. Without this guard the
|
|
6
|
+
* tool would report `success: true` for an action that never happened, so an
|
|
7
|
+
* LLM client would believe a typo'd light was switched on.
|
|
8
|
+
*/
|
|
9
|
+
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
|
10
|
+
import type { HaClient } from '../../haClient/index.js';
|
|
11
|
+
/**
|
|
12
|
+
* Returns an error result when `entityId` does not exist in Home Assistant,
|
|
13
|
+
* or `null` when it does (the caller should then proceed with the action).
|
|
14
|
+
*/
|
|
15
|
+
export declare function entityNotFoundResult(client: HaClient, entityId: string): Promise<CallToolResult | null>;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Existence guard for entity-targeted control tools.
|
|
3
|
+
*
|
|
4
|
+
* Home Assistant silently ignores service calls whose target entity_id does not
|
|
5
|
+
* exist: the call returns 200 with an empty change list. Without this guard the
|
|
6
|
+
* tool would report `success: true` for an action that never happened, so an
|
|
7
|
+
* LLM client would believe a typo'd light was switched on.
|
|
8
|
+
*/
|
|
9
|
+
import { toToolResult } from '../common.js';
|
|
10
|
+
/**
|
|
11
|
+
* Returns an error result when `entityId` does not exist in Home Assistant,
|
|
12
|
+
* or `null` when it does (the caller should then proceed with the action).
|
|
13
|
+
*/
|
|
14
|
+
export async function entityNotFoundResult(client, entityId) {
|
|
15
|
+
const state = await client.getState(entityId);
|
|
16
|
+
if (state) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
return toToolResult({
|
|
20
|
+
error: 'Entity not found',
|
|
21
|
+
entity_id: entityId,
|
|
22
|
+
hint: 'No action was performed. Use the searchEntities tool with a keyword to find the correct entity_id',
|
|
23
|
+
}, true);
|
|
24
|
+
}
|
|
25
|
+
//# sourceMappingURL=entityGuard.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"entityGuard.js","sourceRoot":"","sources":["../../../src/tools/homeassistant/entityGuard.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAIH,OAAO,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAE5C;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,oBAAoB,CAAC,MAAgB,EAAE,QAAgB;IAC3E,MAAM,KAAK,GAAG,MAAM,MAAM,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;IAC9C,IAAI,KAAK,EAAE,CAAC;QACV,OAAO,IAAI,CAAC;IACd,CAAC;IACD,OAAO,YAAY,CAAC;QAClB,KAAK,EAAE,kBAAkB;QACzB,SAAS,EAAE,QAAQ;QACnB,IAAI,EAAE,mGAAmG;KAC1G,EAAE,IAAI,CAAC,CAAC;AACX,CAAC"}
|
|
@@ -2,11 +2,15 @@
|
|
|
2
2
|
* Scene and Script tools - Activate scenes and run scripts
|
|
3
3
|
*/
|
|
4
4
|
import { entityIdSchema, runScriptSchema, toToolResult, wrapToolHandler, Permission } from '../common.js';
|
|
5
|
+
import { entityNotFoundResult } from './entityGuard.js';
|
|
5
6
|
export function registerActivateSceneTool(server, client) {
|
|
6
7
|
server.registerTool('activateScene', {
|
|
7
8
|
description: 'Activate a Home Assistant scene.',
|
|
8
9
|
inputSchema: entityIdSchema.shape,
|
|
9
10
|
}, wrapToolHandler('activateScene', async ({ entity_id }) => {
|
|
11
|
+
const missing = await entityNotFoundResult(client, entity_id);
|
|
12
|
+
if (missing)
|
|
13
|
+
return missing;
|
|
10
14
|
const result = await client.callService({
|
|
11
15
|
domain: 'scene',
|
|
12
16
|
service: 'turn_on',
|
|
@@ -20,6 +24,9 @@ export function registerRunScriptTool(server, client) {
|
|
|
20
24
|
description: 'Run a Home Assistant script with optional variables.',
|
|
21
25
|
inputSchema: runScriptSchema.shape,
|
|
22
26
|
}, wrapToolHandler('runScript', async ({ entity_id, variables }) => {
|
|
27
|
+
const missing = await entityNotFoundResult(client, entity_id);
|
|
28
|
+
if (missing)
|
|
29
|
+
return missing;
|
|
23
30
|
const result = await client.callService({
|
|
24
31
|
domain: 'script',
|
|
25
32
|
service: 'turn_on',
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sceneAndScript.js","sourceRoot":"","sources":["../../../src/tools/homeassistant/sceneAndScript.ts"],"names":[],"mappings":"AAAA;;GAEG;AAIH,OAAO,EAAE,cAAc,EAAE,eAAe,EAAE,YAAY,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"sceneAndScript.js","sourceRoot":"","sources":["../../../src/tools/homeassistant/sceneAndScript.ts"],"names":[],"mappings":"AAAA;;GAEG;AAIH,OAAO,EAAE,cAAc,EAAE,eAAe,EAAE,YAAY,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAC1G,OAAO,EAAE,oBAAoB,EAAE,MAAM,kBAAkB,CAAC;AAMxD,MAAM,UAAU,yBAAyB,CAAC,MAAiB,EAAE,MAAgB;IAC3E,MAAM,CAAC,YAAY,CACjB,eAAe,EACf;QACE,WAAW,EAAE,kCAAkC;QAC/C,WAAW,EAAE,cAAc,CAAC,KAAK;KAClC,EACD,eAAe,CAAC,eAAe,EAAE,KAAK,EAAE,EAAE,SAAS,EAAgB,EAAE,EAAE;QACrE,MAAM,OAAO,GAAG,MAAM,oBAAoB,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC;QAC9D,IAAI,OAAO;YAAE,OAAO,OAAO,CAAC;QAC5B,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,WAAW,CAAC;YACtC,MAAM,EAAE,OAAO;YACf,OAAO,EAAE,SAAS;YAClB,MAAM,EAAE,EAAE,SAAS,EAAE;SACtB,CAAC,CAAC;QACH,OAAO,YAAY,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE,MAAM,EAAE,WAAW,EAAE,OAAO,EAAE,MAAM,CAAC,OAAO,EAAE,CAAC,CAAC;IAClG,CAAC,EAAE,UAAU,CAAC,OAAO,CAAC,CACvB,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,qBAAqB,CAAC,MAAiB,EAAE,MAAgB;IACvE,MAAM,CAAC,YAAY,CACjB,WAAW,EACX;QACE,WAAW,EAAE,sDAAsD;QACnE,WAAW,EAAE,eAAe,CAAC,KAAK;KACnC,EACD,eAAe,CAAC,WAAW,EAAE,KAAK,EAAE,EAAE,SAAS,EAAE,SAAS,EAAiB,EAAE,EAAE;QAC7E,MAAM,OAAO,GAAG,MAAM,oBAAoB,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC;QAC9D,IAAI,OAAO;YAAE,OAAO,OAAO,CAAC;QAC5B,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,WAAW,CAAC;YACtC,MAAM,EAAE,QAAQ;YAChB,OAAO,EAAE,SAAS;YAClB,MAAM,EAAE,EAAE,SAAS,EAAE;YACrB,YAAY,EAAE,SAAgD;SAC/D,CAAC,CAAC;QACH,OAAO,YAAY,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,OAAO,EAAE,MAAM,CAAC,OAAO,EAAE,CAAC,CAAC;IACjG,CAAC,EAAE,UAAU,CAAC,OAAO,CAAC,CACvB,CAAC;AACJ,CAAC"}
|
|
@@ -55,6 +55,12 @@ export declare const readSchema: z.ZodObject<{
|
|
|
55
55
|
export declare function createBrowseHandler(client: OmadaClient): (args: {
|
|
56
56
|
path: string;
|
|
57
57
|
}, extra: ToolExtra) => Promise<import("@modelcontextprotocol/sdk/types").CallToolResult>;
|
|
58
|
+
/**
|
|
59
|
+
* `omada_read` is usable iff the caller holds the permission of at least one readable
|
|
60
|
+
* (fetchable) resource node; otherwise it is hidden from `tools/list`. Kept next to the
|
|
61
|
+
* handler so the rule cannot drift from the per-path check it summarises.
|
|
62
|
+
*/
|
|
63
|
+
export declare function canReadAnyResource(callerPermissions: number): boolean;
|
|
58
64
|
/** Shared `omada_read` handler — same dual-face reuse as createBrowseHandler. */
|
|
59
65
|
export declare function createReadHandler(client: OmadaClient): (args: {
|
|
60
66
|
path: string;
|
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { z } from 'zod';
|
|
18
18
|
import { hasPermission, getPermissionNames } from '../../permissions/index.js';
|
|
19
|
-
import { toToolResult, wrapToolHandler, getCallerPermissions, Permission } from '../common.js';
|
|
20
|
-
import { childrenOf, getResourceNode, normalizePath } from './namespace.js';
|
|
19
|
+
import { toToolResult, wrapToolHandler, getCallerPermissions, setToolVisibilityRule, Permission } from '../common.js';
|
|
20
|
+
import { OMADA_RESOURCES, childrenOf, getResourceNode, normalizePath } from './namespace.js';
|
|
21
21
|
/** Compact, model-friendly metadata for a node (used in browse output). */
|
|
22
22
|
function nodeSummary(node) {
|
|
23
23
|
return {
|
|
@@ -76,8 +76,17 @@ export function createBrowseHandler(client) {
|
|
|
76
76
|
});
|
|
77
77
|
}, Permission.QUERY);
|
|
78
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* `omada_read` is usable iff the caller holds the permission of at least one readable
|
|
81
|
+
* (fetchable) resource node; otherwise it is hidden from `tools/list`. Kept next to the
|
|
82
|
+
* handler so the rule cannot drift from the per-path check it summarises.
|
|
83
|
+
*/
|
|
84
|
+
export function canReadAnyResource(callerPermissions) {
|
|
85
|
+
return OMADA_RESOURCES.some((node) => node.fetch !== undefined && hasPermission(callerPermissions, node.permission));
|
|
86
|
+
}
|
|
79
87
|
/** Shared `omada_read` handler — same dual-face reuse as createBrowseHandler. */
|
|
80
88
|
export function createReadHandler(client) {
|
|
89
|
+
setToolVisibilityRule('omada_read', canReadAnyResource);
|
|
81
90
|
// No static permission: each path declares its own, enforced below (fail-closed).
|
|
82
91
|
return wrapToolHandler('omada_read', async (args, extra) => {
|
|
83
92
|
const norm = normalizePath(args.path);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"graph.js","sourceRoot":"","sources":["../../../src/tools/omada/graph.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAGH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAAE,aAAa,EAAE,kBAAkB,EAAE,MAAM,4BAA4B,CAAC;AAC/E,OAAO,EAAE,YAAY,EAAE,eAAe,EAAE,oBAAoB,EAAE,UAAU,EAAkB,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"graph.js","sourceRoot":"","sources":["../../../src/tools/omada/graph.ts"],"names":[],"mappings":"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"}
|
package/dist/version.d.ts
CHANGED
package/dist/version.js
CHANGED
package/docs/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
### 1.7.6
|
|
2
|
+
- **`omada_read` is hidden from callers who can read no Omada resource.** It has no single static permission (it checks per resource path), so 1.7.5 still listed it to every caller, including unmapped tokens that could read nothing. A new per-tool visibility rule lists it only when the caller holds the permission of at least one readable node (e.g. a `QUERY` or an `ADMIN`-only mask); a `NONE`/`NOTIFY`-only caller no longer sees it. Per-path checks are unchanged.
|
|
3
|
+
- **CI: `actions/checkout` and `actions/setup-node` v4 → v7** (Node 24 runtime; v4 targeted the deprecated Node 20 action runtime). No workflow changes were needed: the breaking changes in v5–v7 concern automatic caching (not triggered — no `packageManager` field) and credential persistence (CI never pushes with git).
|
|
4
|
+
- `AGENTS.md`: the GHCR push token is fetched from the maintainer's password manager at push time — never stored as an environment variable.
|
|
5
|
+
- +4 tests (`omada_read` visibility for NONE / QUERY / ADMIN-only / NOTIFY-only masks).
|
|
6
|
+
|
|
7
|
+
### 1.7.5
|
|
8
|
+
- **Security — `tools/list` is filtered by the caller's permissions.** A token is only offered the tools it may call (e.g. a `readonly` token no longer sees `restartHomeAssistant`, `deleteAutomation`, `invokeAction` or the Omada write tools). Previously every valid token was shown all tools; calls were already denied, but LLM clients planned around tools they could not use and the admin surface was disclosed. Each tool's static permission is recorded by `wrapToolHandler`; `createServer({ callerPermissions })` disables the rest, so the SDK omits them from the list and rejects calls to them. Per-call RBAC is unchanged; stdio (local trust) is unfiltered; `omada_read`/`omada_browse` stay listed (they gate per resource path).
|
|
9
|
+
- **Fix — control tools no longer report success for a non-existent entity.** Home Assistant silently ignores service calls to unknown `entity_id`s, so `entityAction`, `controlLight`, `controlClimate`, `controlFan`, `controlCover`, `controlMediaPlayer`, `activateScene` and `runScript` returned `success: true` for typos. They now check existence first and return `isError` "Entity not found" ("No action was performed") without calling the service.
|
|
10
|
+
- **Warning — permissive `defaultRole`.** With bearer auth, the server logs a startup warning when `MCP_PERMISSIONS_CONFIG` `defaultRole` grants anything, since it applies to every valid token with an unmapped `sub`. README now recommends `defaultRole: NONE` plus one `users` entry per client (and fixes the `ADMIN` role row, which wrongly said "all except AI").
|
|
11
|
+
- **Fix — `generate:jwt` prints the token once.** stdout carries only the bare token; details go to stderr, so `TOKEN=$(npm run -s generate:jwt -- ...)` captures exactly one token.
|
|
12
|
+
- Added `.codex/review-rules.md` (the repo's hard rules for pre-release code review).
|
|
13
|
+
- +26 tests: permission-filtered `tools/list` over an MCP client and over the real HTTP path (bearer auth → stream transport, incl. interleaved callers), the entity guard for all eight tools, `defaultRoleWarning` and its wiring in the auth middleware, and the `generate:jwt` stdout contract (subprocess).
|
|
14
|
+
|
|
1
15
|
### 1.7.4
|
|
2
16
|
- **Security — cleared every advisory in the dependency tree** (`npm audit` and `npm audit --omit=dev` both report 0). Runtime range declarations are unchanged; all fixes are in-range lockfile bumps:
|
|
3
17
|
- Production: `undici` 6.28.0→6.29.0 (WebSocket DoS ×2, retry-interceptor response splitting), `fast-uri` 3.1.6→3.1.8 (authority injection / host confusion; via ajv), `hono` 4.13.3→4.13.11, `ip-address` 10.5.0→10.7.2, `qs` 6.15.3→6.16.0 (all three transitive under the SDK's express stack).
|
package/openapi.json
CHANGED
package/package.json
CHANGED
package/scripts/generate-jwt.ts
CHANGED
|
@@ -107,16 +107,15 @@ function main() {
|
|
|
107
107
|
|
|
108
108
|
const token = createJwt(payload, secret);
|
|
109
109
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
console.
|
|
113
|
-
console.
|
|
114
|
-
console.
|
|
115
|
-
console.
|
|
110
|
+
// Details go to stderr; stdout carries ONLY the bare token, exactly once, so
|
|
111
|
+
// `TOKEN=$(npm run -s generate:jwt -- ...)` captures one usable token.
|
|
112
|
+
console.error('\n=== JWT Token Generated ===\n');
|
|
113
|
+
console.error('Subject:', sub);
|
|
114
|
+
console.error('Issued at:', new Date(iat * 1000).toISOString());
|
|
115
|
+
console.error('Expires at:', new Date(expTime * 1000).toISOString());
|
|
116
|
+
console.error('Duration:', exp);
|
|
117
|
+
console.error('\nUsage: curl -H "Authorization: Bearer <token>" http://localhost:3000/mcp ...\n');
|
|
116
118
|
console.log(token);
|
|
117
|
-
console.log('\n--- Usage ---\n');
|
|
118
|
-
console.log(`curl -H "Authorization: Bearer ${token}" http://localhost:3000/mcp ...`);
|
|
119
|
-
console.log('');
|
|
120
119
|
}
|
|
121
120
|
|
|
122
121
|
main();
|
package/src/permissions/index.ts
CHANGED
|
@@ -80,6 +80,24 @@ export function getUserPermissions(sub: string | undefined, config: PermissionsC
|
|
|
80
80
|
return resolveRoleName(user.role);
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Warning text when `defaultRole` grants anything, or `undefined` when it is NONE.
|
|
85
|
+
*
|
|
86
|
+
* `defaultRole` applies to every validly signed token whose `sub` is not listed in
|
|
87
|
+
* `users` — so a non-NONE default silently gives unmapped clients real access. The
|
|
88
|
+
* recommended setup is `defaultRole: none` plus one explicit `users` entry per client.
|
|
89
|
+
*/
|
|
90
|
+
export function defaultRoleWarning(config: PermissionsConfig): string | undefined {
|
|
91
|
+
const mask = resolveRoleName(config.defaultRole);
|
|
92
|
+
if (mask === Role.NONE) {
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
return (
|
|
96
|
+
`MCP_PERMISSIONS_CONFIG defaultRole is "${config.defaultRole}": any valid token with an unmapped sub ` +
|
|
97
|
+
`gets [${getPermissionNames(mask).join(', ')}]. Recommended: defaultRole "none" and one users[] entry per client.`
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
83
101
|
/**
|
|
84
102
|
* Check if a permission mask includes required permission
|
|
85
103
|
*/
|
package/src/server/auth.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
6
6
|
import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js';
|
|
7
7
|
import { verifyJwt, type JwtPayload } from '../utils/jwt.js';
|
|
8
|
-
import { getUserPermissions, Role, type PermissionsConfig } from '../permissions/index.js';
|
|
8
|
+
import { defaultRoleWarning, getUserPermissions, Role, type PermissionsConfig } from '../permissions/index.js';
|
|
9
9
|
import { logger } from '../utils/logger.js';
|
|
10
10
|
|
|
11
11
|
export type AuthMethod = 'none' | 'bearer';
|
|
@@ -99,6 +99,16 @@ function validateJwt(
|
|
|
99
99
|
export function createAuthMiddleware(config: AuthConfig): (req: IncomingMessage, res: ServerResponse) => boolean {
|
|
100
100
|
const { method, secret, permissions: permConfig, skipPaths = [], requireExp, issuer, audience } = config;
|
|
101
101
|
|
|
102
|
+
// Loudly flag a permissive defaultRole: it silently grants every valid token whose
|
|
103
|
+
// sub is unmapped. Lives here (not in server startup) because this middleware is
|
|
104
|
+
// what applies defaultRole, so the warning cannot be bypassed.
|
|
105
|
+
if (method === 'bearer' && permConfig) {
|
|
106
|
+
const warning = defaultRoleWarning(permConfig);
|
|
107
|
+
if (warning) {
|
|
108
|
+
logger.warn(warning);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
102
112
|
return (req: IncomingMessage, res: ServerResponse): boolean => {
|
|
103
113
|
if (method === 'none') {
|
|
104
114
|
// No auth = full permissions via AuthInfo.extra
|
package/src/server/common.ts
CHANGED
|
@@ -3,11 +3,12 @@
|
|
|
3
3
|
* Shared across all transport types (stdio, SSE, stream)
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
6
|
+
import { McpServer, type RegisteredTool } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
7
7
|
import type { HaClient } from '../haClient/index.js';
|
|
8
8
|
import type { LocalAIClient } from '../localAI/index.js';
|
|
9
9
|
import type { OmadaClient } from '../omadaClient/index.js';
|
|
10
10
|
import { registerAllTools } from '../tools/index.js';
|
|
11
|
+
import { isToolVisibleTo } from '../tools/common.js';
|
|
11
12
|
import type { OmadaRegistrationMode } from '../tools/omada/index.js';
|
|
12
13
|
import type { RestAction } from '../tools/infra/index.js';
|
|
13
14
|
import { registerAllResources } from '../resources/index.js';
|
|
@@ -23,10 +24,17 @@ export interface CreateServerOptions {
|
|
|
23
24
|
restActions?: Record<string, RestAction>;
|
|
24
25
|
/** Tool registration strategy for the Omada plugin (default 'eager'). */
|
|
25
26
|
toolRegistrationMode?: OmadaRegistrationMode;
|
|
27
|
+
/**
|
|
28
|
+
* The caller's permission mask. When set, tools whose static permission the
|
|
29
|
+
* caller lacks are disabled, which hides them from `tools/list` (and the SDK
|
|
30
|
+
* rejects calls to them). Undefined = no filtering (stdio local trust).
|
|
31
|
+
* Execution-time RBAC in wrapToolHandler still applies either way.
|
|
32
|
+
*/
|
|
33
|
+
callerPermissions?: number;
|
|
26
34
|
}
|
|
27
35
|
|
|
28
36
|
export function createServer(options: CreateServerOptions): McpServer {
|
|
29
|
-
const { haClient, omadaClient, aiClient, restActions, toolRegistrationMode } = options;
|
|
37
|
+
const { haClient, omadaClient, aiClient, restActions, toolRegistrationMode, callerPermissions } = options;
|
|
30
38
|
logger.debug('Creating MCP server instance');
|
|
31
39
|
|
|
32
40
|
// Generate instructions based on enabled plugins
|
|
@@ -48,15 +56,32 @@ export function createServer(options: CreateServerOptions): McpServer {
|
|
|
48
56
|
|
|
49
57
|
logger.debug('Server instructions generated', { length: instructions.length });
|
|
50
58
|
|
|
51
|
-
// Register all tools based on configured clients
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
});
|
|
59
|
+
// Register all tools based on configured clients, capturing each RegisteredTool
|
|
60
|
+
// handle so tools the caller can never use can be hidden afterwards.
|
|
61
|
+
const registeredTools = new Map<string, RegisteredTool>();
|
|
62
|
+
const originalRegisterTool = server.registerTool;
|
|
63
|
+
server.registerTool = ((...args: Parameters<McpServer['registerTool']>) => {
|
|
64
|
+
const tool = (originalRegisterTool as (...a: unknown[]) => RegisteredTool).apply(server, args);
|
|
65
|
+
registeredTools.set(args[0], tool);
|
|
66
|
+
return tool;
|
|
67
|
+
}) as McpServer['registerTool'];
|
|
68
|
+
try {
|
|
69
|
+
registerAllTools({
|
|
70
|
+
server,
|
|
71
|
+
haClient,
|
|
72
|
+
omadaClient,
|
|
73
|
+
aiClient,
|
|
74
|
+
restActions,
|
|
75
|
+
toolRegistrationMode,
|
|
76
|
+
});
|
|
77
|
+
} finally {
|
|
78
|
+
server.registerTool = originalRegisterTool;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (callerPermissions !== undefined) {
|
|
82
|
+
const hidden = hideUnpermittedTools(registeredTools, callerPermissions);
|
|
83
|
+
logger.debug('Filtered tools by caller permissions', { visible: registeredTools.size - hidden, hidden });
|
|
84
|
+
}
|
|
60
85
|
|
|
61
86
|
// Register Home Assistant resources (if HA client provided)
|
|
62
87
|
if (haClient) {
|
|
@@ -67,3 +92,20 @@ export function createServer(options: CreateServerOptions): McpServer {
|
|
|
67
92
|
|
|
68
93
|
return server;
|
|
69
94
|
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Disable every tool the caller cannot use (it lacks the tool's static permission, or the
|
|
98
|
+
* tool's visibility rule — e.g. omada_read's per-path one — finds nothing usable). Disabled
|
|
99
|
+
* tools are omitted from `tools/list`, so an LLM client only plans with tools it can use.
|
|
100
|
+
* Returns the number of tools hidden.
|
|
101
|
+
*/
|
|
102
|
+
export function hideUnpermittedTools(tools: Map<string, RegisteredTool>, callerPermissions: number): number {
|
|
103
|
+
let hidden = 0;
|
|
104
|
+
for (const [name, tool] of tools) {
|
|
105
|
+
if (!isToolVisibleTo(name, callerPermissions)) {
|
|
106
|
+
tool.disable();
|
|
107
|
+
hidden++;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return hidden;
|
|
111
|
+
}
|
package/src/server/stream.ts
CHANGED
|
@@ -12,6 +12,7 @@ import type { LocalAIClient } from '../localAI/index.js';
|
|
|
12
12
|
import type { OmadaClient } from '../omadaClient/index.js';
|
|
13
13
|
import { logger } from '../utils/logger.js';
|
|
14
14
|
import { createServer } from './common.js';
|
|
15
|
+
import type { AuthenticatedRequest } from './auth.js';
|
|
15
16
|
|
|
16
17
|
interface StreamTransportState {
|
|
17
18
|
transport: StreamableHTTPServerTransport;
|
|
@@ -92,7 +93,7 @@ export function buildTransportSecurityOptions(config: EnvironmentConfig): Transp
|
|
|
92
93
|
* Creates a Streamable HTTP transport
|
|
93
94
|
* This implements the MCP protocol version 2025-03-26
|
|
94
95
|
*/
|
|
95
|
-
export function createStreamTransport(options: StreamTransportOptions): StreamTransportState {
|
|
96
|
+
export function createStreamTransport(options: StreamTransportOptions, callerPermissions?: number): StreamTransportState {
|
|
96
97
|
const { haClient, omadaClient, aiClient, config } = options;
|
|
97
98
|
const mcpServer = createServer({
|
|
98
99
|
haClient,
|
|
@@ -100,6 +101,8 @@ export function createStreamTransport(options: StreamTransportOptions): StreamTr
|
|
|
100
101
|
aiClient,
|
|
101
102
|
restActions: config.restActions,
|
|
102
103
|
toolRegistrationMode: config.toolRegistrationMode,
|
|
104
|
+
// Hide tools this caller can never use from tools/list (RBAC still enforced per call).
|
|
105
|
+
callerPermissions,
|
|
103
106
|
});
|
|
104
107
|
|
|
105
108
|
const enableStatefulSessions = config.stateful;
|
|
@@ -158,7 +161,9 @@ export async function handleStreamRequest(
|
|
|
158
161
|
});
|
|
159
162
|
|
|
160
163
|
// Reuse existing transport if provided, otherwise create new one
|
|
161
|
-
|
|
164
|
+
// The auth middleware attached the caller's mask; a new server lists only the tools it permits.
|
|
165
|
+
const callerPermissions = (req as AuthenticatedRequest).auth?.extra?.permissions as number | undefined;
|
|
166
|
+
const state = existingTransport ?? createStreamTransport(options, callerPermissions);
|
|
162
167
|
|
|
163
168
|
if (!existingTransport) {
|
|
164
169
|
await state.server.connect(state.transport);
|
package/src/tools/common.ts
CHANGED
|
@@ -73,6 +73,45 @@ export function safeSerialize(value: unknown): string {
|
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
/**
|
|
77
|
+
* Static permission bit each tool declares via `wrapToolHandler`, keyed by tool
|
|
78
|
+
* name. Lets the server hide tools a caller can never use from `tools/list`.
|
|
79
|
+
* Tools with no static bit (e.g. `omada_read`/`omada_browse`, which gate per
|
|
80
|
+
* resource path) are absent and therefore always listed.
|
|
81
|
+
*/
|
|
82
|
+
const toolPermissions = new Map<string, number>();
|
|
83
|
+
|
|
84
|
+
/** The static permission a tool requires, or `undefined` when it declares none. */
|
|
85
|
+
export function getToolRequiredPermission(name: string): number | undefined {
|
|
86
|
+
return toolPermissions.get(name);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Visibility rules for tools that gate per call rather than with one static bit
|
|
91
|
+
* (e.g. `omada_read`, whose permission depends on the resource path). The rule
|
|
92
|
+
* answers "can this caller use the tool at all?".
|
|
93
|
+
*/
|
|
94
|
+
const toolVisibilityRules = new Map<string, (callerPermissions: number) => boolean>();
|
|
95
|
+
|
|
96
|
+
/** Declare when a tool without a static permission should be listed to a caller. */
|
|
97
|
+
export function setToolVisibilityRule(name: string, isUsable: (callerPermissions: number) => boolean): void {
|
|
98
|
+
toolVisibilityRules.set(name, isUsable);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Whether `tools/list` should offer `name` to a caller: false when the caller lacks
|
|
103
|
+
* the tool's static permission, or when its visibility rule says the caller can use
|
|
104
|
+
* none of it. Tools with neither stay visible (their handlers still enforce RBAC).
|
|
105
|
+
*/
|
|
106
|
+
export function isToolVisibleTo(name: string, callerPermissions: number): boolean {
|
|
107
|
+
const required = toolPermissions.get(name);
|
|
108
|
+
if (required !== undefined && !hasPermission(callerPermissions, required)) {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
const rule = toolVisibilityRules.get(name);
|
|
112
|
+
return rule ? rule(callerPermissions) : true;
|
|
113
|
+
}
|
|
114
|
+
|
|
76
115
|
/**
|
|
77
116
|
* Wrap a tool handler with logging, error handling, and permission checking
|
|
78
117
|
* Includes session ID and args in logs for debugging
|
|
@@ -86,6 +125,9 @@ export function wrapToolHandler<T>(
|
|
|
86
125
|
handler: (args: T, extra: ToolExtra) => Promise<CallToolResult>,
|
|
87
126
|
requiredPermission?: number
|
|
88
127
|
): (args: T, extra: ToolExtra) => Promise<CallToolResult> {
|
|
128
|
+
if (requiredPermission !== undefined) {
|
|
129
|
+
toolPermissions.set(name, requiredPermission);
|
|
130
|
+
}
|
|
89
131
|
return async (args: T, extra: ToolExtra): Promise<CallToolResult> => {
|
|
90
132
|
const sessionId = extra.sessionId ?? 'unknown-session';
|
|
91
133
|
// Extract permissions from MCP SDK authInfo.extra.permissions.
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
6
6
|
import type { HaClient } from '../../haClient/index.js';
|
|
7
7
|
import { controlClimateSchema, toToolResult, wrapToolHandler, Permission } from '../common.js';
|
|
8
|
+
import { entityNotFoundResult } from './entityGuard.js';
|
|
8
9
|
import type { z } from 'zod';
|
|
9
10
|
|
|
10
11
|
type ControlClimateArgs = z.infer<typeof controlClimateSchema>;
|
|
@@ -17,6 +18,8 @@ export function registerControlClimateTool(server: McpServer, client: HaClient):
|
|
|
17
18
|
inputSchema: controlClimateSchema.shape,
|
|
18
19
|
},
|
|
19
20
|
wrapToolHandler('controlClimate', async (args: ControlClimateArgs) => {
|
|
21
|
+
const missing = await entityNotFoundResult(client, args.entity_id);
|
|
22
|
+
if (missing) return missing;
|
|
20
23
|
const results: unknown[] = [];
|
|
21
24
|
|
|
22
25
|
if (args.hvac_mode !== undefined) {
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
6
6
|
import type { HaClient } from '../../haClient/index.js';
|
|
7
7
|
import { controlCoverSchema, toToolResult, wrapToolHandler, Permission } from '../common.js';
|
|
8
|
+
import { entityNotFoundResult } from './entityGuard.js';
|
|
8
9
|
import type { z } from 'zod';
|
|
9
10
|
|
|
10
11
|
type ControlCoverArgs = z.infer<typeof controlCoverSchema>;
|
|
@@ -17,6 +18,8 @@ export function registerControlCoverTool(server: McpServer, client: HaClient): v
|
|
|
17
18
|
inputSchema: controlCoverSchema.shape,
|
|
18
19
|
},
|
|
19
20
|
wrapToolHandler('controlCover', async (args: ControlCoverArgs) => {
|
|
21
|
+
const missing = await entityNotFoundResult(client, args.entity_id);
|
|
22
|
+
if (missing) return missing;
|
|
20
23
|
const actionMap: Record<string, string> = {
|
|
21
24
|
open: 'open_cover',
|
|
22
25
|
close: 'close_cover',
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
6
6
|
import type { HaClient } from '../../haClient/index.js';
|
|
7
7
|
import { controlFanSchema, toToolResult, wrapToolHandler, Permission } from '../common.js';
|
|
8
|
+
import { entityNotFoundResult } from './entityGuard.js';
|
|
8
9
|
import type { z } from 'zod';
|
|
9
10
|
|
|
10
11
|
type ControlFanArgs = z.infer<typeof controlFanSchema>;
|
|
@@ -17,6 +18,8 @@ export function registerControlFanTool(server: McpServer, client: HaClient): voi
|
|
|
17
18
|
inputSchema: controlFanSchema.shape,
|
|
18
19
|
},
|
|
19
20
|
wrapToolHandler('controlFan', async (args: ControlFanArgs) => {
|
|
21
|
+
const missing = await entityNotFoundResult(client, args.entity_id);
|
|
22
|
+
if (missing) return missing;
|
|
20
23
|
let service: string = args.action;
|
|
21
24
|
const serviceData: Record<string, unknown> = {};
|
|
22
25
|
|