Skip to content

Commit 6ecf1a6

Browse files
authored
MCP Integration: Add get-history as MCP tool (#2377)
1 parent a58b7fb commit 6ecf1a6

File tree

9 files changed

+339
-52
lines changed

9 files changed

+339
-52
lines changed

server/services/mcp/index.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,16 @@ module.exports = function MCPService(gladys, serviceId) {
77
const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js');
88
// eslint-disable-next-line import/no-unresolved, import/extensions
99
const { StreamableHTTPServerTransport } = require('@modelcontextprotocol/sdk/server/streamableHttp.js');
10+
// eslint-disable-next-line import/no-unresolved
11+
const { encode } = require('@toon-format/toon');
1012
const levenshtein = require('fastest-levenshtein');
1113

1214
const mcp = {
1315
McpServer,
1416
StreamableHTTPServerTransport,
1517
};
1618

17-
const mcpHandler = new MCPHandler(gladys, serviceId, mcp, levenshtein);
19+
const mcpHandler = new MCPHandler(gladys, serviceId, mcp, encode, levenshtein);
1820

1921
/**
2022
* @public

server/services/mcp/lib/buildSchemas.js

Lines changed: 132 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,24 @@ const noRoom = {
66
selector: 'no-room',
77
};
88

9+
const ONE_HOUR_IN_MINUTES = 60;
10+
const TWELVE_HOURS_IN_MINUTES = 12 * 60;
11+
const ONE_DAY_IN_MINUTES = 24 * 60;
12+
const SEVEN_DAYS_IN_MINUTES = 7 * 24 * 60;
13+
const THIRTY_DAYS_IN_MINUTES = 30 * 24 * 60;
14+
const THREE_MONTHS_IN_MINUTES = 3 * 30 * 24 * 60;
15+
const ONE_YEAR_IN_MINUTES = 365 * 24 * 60;
16+
17+
const intervalByName = {
18+
'last-hour': ONE_HOUR_IN_MINUTES,
19+
'last-twelve-hours': TWELVE_HOURS_IN_MINUTES,
20+
'last-day': ONE_DAY_IN_MINUTES,
21+
'last-week': SEVEN_DAYS_IN_MINUTES,
22+
'last-month': THIRTY_DAYS_IN_MINUTES,
23+
'last-three-months': THREE_MONTHS_IN_MINUTES,
24+
'last-year': ONE_YEAR_IN_MINUTES,
25+
};
26+
927
/**
1028
* @description Get all resources (room and devices) available for the MCP service.
1129
* @returns {Promise<Array>} Array of resources with home schema configuration.
@@ -148,6 +166,25 @@ async function getAllTools() {
148166
),
149167
];
150168

169+
const historyDevices = (await this.gladys.device.get())
170+
.filter((device) => {
171+
return device.features.some((feature) => this.isHistoryFeature(feature));
172+
})
173+
.map((device) => ({
174+
...device,
175+
name: device.name,
176+
features: device.features.filter((feature) => this.isHistoryFeature(feature)),
177+
}));
178+
const availableHistoryFeature = [
179+
...new Set(
180+
historyDevices
181+
.map((device) => {
182+
return device.features.map((feature) => `${feature.category}:${feature.type}`);
183+
})
184+
.flat(),
185+
),
186+
];
187+
151188
return [
152189
{
153190
intent: 'camera.get-image',
@@ -254,10 +291,12 @@ async function getAllTools() {
254291
);
255292

256293
return {
257-
content: states.map((state) => ({
258-
type: 'text',
259-
text: JSON.stringify(state),
260-
})),
294+
content: [
295+
{
296+
type: 'text',
297+
text: this.toon(states),
298+
},
299+
],
261300
};
262301
},
263302
},
@@ -341,6 +380,95 @@ async function getAllTools() {
341380
};
342381
},
343382
},
383+
{
384+
intent: 'device.get-history',
385+
config: {
386+
title: 'Get device history',
387+
description: 'Get history states of specific device.',
388+
inputSchema: {
389+
room: z
390+
.enum(rooms.map(({ name }) => name))
391+
.describe('Room to get information from.')
392+
.optional(),
393+
device: z
394+
.enum([...new Set(historyDevices.map(({ name }) => name))])
395+
.describe('Device name to get history.')
396+
.optional(),
397+
feature: z
398+
.enum(availableHistoryFeature)
399+
.describe('Type of device to query.')
400+
.optional(),
401+
interval: z
402+
.enum(Object.keys(intervalByName))
403+
.describe('Time interval to get history from.')
404+
.optional(),
405+
},
406+
},
407+
cb: async ({ room, device, feature, interval }) => {
408+
let selectedDevices = historyDevices;
409+
410+
if (room && room !== '') {
411+
const { selector } = this.findBySimilarity(rooms, room);
412+
selectedDevices = selectedDevices.filter((d) => (d.room?.selector || noRoom.selector) === selector);
413+
}
414+
415+
if (feature && feature !== '') {
416+
const [featureCategory, featureType] = feature.split(':');
417+
selectedDevices = selectedDevices.filter((d) => {
418+
return d.features.some(
419+
(f) => f.category === featureCategory && (featureType ? f.type === featureType : true),
420+
);
421+
});
422+
}
423+
424+
if (device && device !== '') {
425+
const deviceFound = this.findBySimilarity(selectedDevices, device);
426+
if (deviceFound?.name) {
427+
selectedDevices = [deviceFound];
428+
}
429+
}
430+
431+
if (selectedDevices.length > 0) {
432+
const selectedFeature =
433+
selectedDevices[0].features.find((f) => {
434+
if (feature && feature !== '') {
435+
const [featureCategory, featureType] = feature.split(':');
436+
437+
return f.category === featureCategory && (featureType ? f.type === featureType : true);
438+
}
439+
440+
return false;
441+
}) || selectedDevices[0].features[0];
442+
443+
const aggStates = await this.gladys.device.getDeviceFeaturesAggregates(
444+
selectedFeature.selector,
445+
interval ? intervalByName[interval] : THIRTY_DAYS_IN_MINUTES,
446+
500,
447+
);
448+
449+
return {
450+
content: [
451+
{
452+
type: 'text',
453+
text: this.toon({
454+
room: selectedDevices[0].room?.name || noRoom.name,
455+
device: selectedDevices[0].name,
456+
feature: selectedFeature.name,
457+
category: selectedFeature.category,
458+
type: selectedFeature.type,
459+
unit: this.formatValue(selectedFeature).unit,
460+
values: aggStates.values,
461+
}),
462+
},
463+
],
464+
};
465+
}
466+
467+
return {
468+
content: [{ type: 'text', text: `device.get-history, no device or feature found` }],
469+
};
470+
},
471+
},
344472
];
345473
}
346474

server/services/mcp/lib/formatValue.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,14 @@ function formatValue(feature) {
1010
case 'opening-sensor:binary':
1111
return {
1212
value: feature.last_value === 0 ? 'open' : 'closed',
13+
unit: null,
1314
};
1415
case 'light:binary':
1516
case 'switch:binary':
1617
case 'air-conditioning':
1718
return {
1819
value: feature.last_value === 0 ? 'off' : 'on',
20+
unit: null,
1921
};
2022
default:
2123
return {

server/services/mcp/lib/index.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ const { createServer } = require('./createServer');
22
const { getAllResources, getAllTools } = require('./buildSchemas');
33
const { formatValue } = require('./formatValue');
44
const { proxy } = require('./mcp.proxy');
5-
const { isSensorFeature, isSwitchableFeature } = require('./selectFeature');
5+
const { isSensorFeature, isSwitchableFeature, isHistoryFeature } = require('./selectFeature');
66
const { findBySimilarity } = require('./findBySimilarity');
77
const { eventFunctionWrapper } = require('../../../utils/functionsWrapper');
88
const { EVENTS } = require('../../../utils/constants');
@@ -12,14 +12,16 @@ const { EVENTS } = require('../../../utils/constants');
1212
* @param {object} gladys - Gladys instance.
1313
* @param {string} serviceId - UUID of the service in DB.
1414
* @param {object} mcp - MCP library.
15+
* @param {object} toon - Toon encoding library.
1516
* @param {object} levenshtein - Levenshtein library.
1617
* @example
1718
* const mcpHandler = new MCPHandler(gladys, serviceId, mcp);
1819
*/
19-
const MCPHandler = function MCPHandler(gladys, serviceId, mcp, levenshtein) {
20+
const MCPHandler = function MCPHandler(gladys, serviceId, mcp, toon, levenshtein) {
2021
this.gladys = gladys;
2122
this.serviceId = serviceId;
2223
this.mcp = mcp;
24+
this.toon = toon;
2325
this.levenshtein = levenshtein;
2426
this.server = null;
2527
this.transports = {};
@@ -33,6 +35,7 @@ MCPHandler.prototype.getAllResources = getAllResources;
3335
MCPHandler.prototype.getAllTools = getAllTools;
3436
MCPHandler.prototype.isSensorFeature = isSensorFeature;
3537
MCPHandler.prototype.isSwitchableFeature = isSwitchableFeature;
38+
MCPHandler.prototype.isHistoryFeature = isHistoryFeature;
3639
MCPHandler.prototype.findBySimilarity = findBySimilarity;
3740
MCPHandler.prototype.formatValue = formatValue;
3841
MCPHandler.prototype.proxy = proxy;

server/services/mcp/lib/selectFeature.js

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,45 @@ const isSwitchableFeature = (deviceFeature) => {
4444
);
4545
};
4646

47+
const historyFeatures = [
48+
DEVICE_FEATURE_CATEGORIES.AIRQUALITY_SENSOR,
49+
DEVICE_FEATURE_CATEGORIES.CO_SENSOR,
50+
DEVICE_FEATURE_CATEGORIES.CO2_SENSOR,
51+
DEVICE_FEATURE_CATEGORIES.HUMIDITY_SENSOR,
52+
DEVICE_FEATURE_CATEGORIES.LIGHT_SENSOR,
53+
DEVICE_FEATURE_CATEGORIES.MOTION_SENSOR,
54+
DEVICE_FEATURE_CATEGORIES.PM10_SENSOR,
55+
DEVICE_FEATURE_CATEGORIES.PM25_SENSOR,
56+
DEVICE_FEATURE_CATEGORIES.FORMALDEHYD_SENSOR,
57+
DEVICE_FEATURE_CATEGORIES.PRECIPITATION_SENSOR,
58+
DEVICE_FEATURE_CATEGORIES.PRESENCE_SENSOR,
59+
DEVICE_FEATURE_CATEGORIES.PRESSURE_SENSOR,
60+
DEVICE_FEATURE_CATEGORIES.RAIN_SENSOR,
61+
DEVICE_FEATURE_CATEGORIES.SMOKE_SENSOR,
62+
DEVICE_FEATURE_CATEGORIES.TEMPERATURE_SENSOR,
63+
DEVICE_FEATURE_CATEGORIES.VOC_SENSOR,
64+
DEVICE_FEATURE_CATEGORIES.VOLUME_SENSOR,
65+
`${DEVICE_FEATURE_CATEGORIES.ENERGY_SENSOR}:${DEVICE_FEATURE_TYPES.ENERGY_SENSOR.POWER}`,
66+
`${DEVICE_FEATURE_CATEGORIES.ENERGY_SENSOR}:${DEVICE_FEATURE_TYPES.ENERGY_SENSOR.ENERGY}`,
67+
`${DEVICE_FEATURE_CATEGORIES.ENERGY_SENSOR}:${DEVICE_FEATURE_TYPES.ENERGY_SENSOR.VOLTAGE}`,
68+
`${DEVICE_FEATURE_CATEGORIES.ENERGY_SENSOR}:${DEVICE_FEATURE_TYPES.ENERGY_SENSOR.CURRENT}`,
69+
`${DEVICE_FEATURE_CATEGORIES.ENERGY_SENSOR}:${DEVICE_FEATURE_TYPES.ENERGY_SENSOR.INDEX}`,
70+
`${DEVICE_FEATURE_CATEGORIES.ENERGY_SENSOR}:${DEVICE_FEATURE_TYPES.ENERGY_SENSOR.DAILY_CONSUMPTION}`,
71+
`${DEVICE_FEATURE_CATEGORIES.SWITCH}:${DEVICE_FEATURE_TYPES.SWITCH.POWER}`,
72+
`${DEVICE_FEATURE_CATEGORIES.SWITCH}:${DEVICE_FEATURE_TYPES.SWITCH.ENERGY}`,
73+
`${DEVICE_FEATURE_CATEGORIES.SWITCH}:${DEVICE_FEATURE_TYPES.SWITCH.VOLTAGE}`,
74+
`${DEVICE_FEATURE_CATEGORIES.SWITCH}:${DEVICE_FEATURE_TYPES.SWITCH.CURRENT}`,
75+
];
76+
77+
const isHistoryFeature = (deviceFeature) => {
78+
return historyFeatures.some(
79+
(types) =>
80+
`${deviceFeature.category}:${deviceFeature.type}`.startsWith(types) && deviceFeature.keep_history === true,
81+
);
82+
};
83+
4784
module.exports = {
4885
isSensorFeature,
4986
isSwitchableFeature,
87+
isHistoryFeature,
5088
};

0 commit comments

Comments
 (0)