@thinger-io/cli-core 0.1.0 → 0.2.0
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/CHANGELOG.md +100 -0
- package/README.md +37 -5
- package/lib/api.js +3 -0
- package/lib/auth.js +29 -9
- package/lib/bucket.js +3 -0
- package/lib/cli.js +15 -2
- package/lib/config.js +55 -5
- package/lib/constants.js +8 -0
- package/lib/dashboard.js +605 -0
- package/lib/docs.js +182 -0
- package/lib/errors.js +29 -4
- package/lib/mcp/catalog.js +1235 -0
- package/lib/mcp/helpers.js +1 -1
- package/lib/mcp/registry.js +72 -30
- package/lib/mcp/resource-hooks.js +145 -0
- package/lib/mcp/server.js +46 -5
- package/lib/mcp/tools-buckets.js +79 -80
- package/lib/mcp/tools-dashboards.js +123 -0
- package/lib/mcp/tools-devices.js +88 -234
- package/lib/mcp/tools-misc.js +48 -0
- package/lib/mcp/tools-plugins.js +63 -0
- package/lib/mcp/tools-products.js +32 -385
- package/lib/mcp/tools-resource.js +760 -0
- package/lib/mcp/tools-storage.js +173 -0
- package/lib/openapi.js +169 -0
- package/lib/paths.js +8 -0
- package/lib/resource.js +2 -0
- package/lib/storage.js +2 -1
- package/openapi/swagger.json +1 -0
- package/package.json +3 -2
- package/lib/mcp/tools-alarms.js +0 -507
- package/lib/mcp/tools-profile.js +0 -635
- package/lib/mcp/tools-profiles.js +0 -30
- package/lib/mcp/tools-properties.js +0 -84
- package/lib/mcp/tools-resources.js +0 -83
- package/lib/mcp/tools-tokens.js +0 -370
package/lib/mcp/helpers.js
CHANGED
|
@@ -11,7 +11,7 @@ export async function withProfile(profile, fn) {
|
|
|
11
11
|
if (!profile) return fn();
|
|
12
12
|
if (!getProfile(profile)) {
|
|
13
13
|
throw inputError(
|
|
14
|
-
`Unknown profile: ${profile}. Use ${toolName('
|
|
14
|
+
`Unknown profile: ${profile}. Use ${toolName('profile_list')} to list available ones.`,
|
|
15
15
|
);
|
|
16
16
|
}
|
|
17
17
|
return withActiveProfile(profile, fn);
|
package/lib/mcp/registry.js
CHANGED
|
@@ -1,30 +1,44 @@
|
|
|
1
1
|
// @ts-check
|
|
2
|
-
import { tools as
|
|
3
|
-
import { tools as
|
|
4
|
-
import { tools as
|
|
5
|
-
import { tools as
|
|
6
|
-
import { tools as
|
|
7
|
-
import { tools as
|
|
8
|
-
import {
|
|
9
|
-
import { tools as
|
|
10
|
-
import {
|
|
2
|
+
import { tools as bucketTools } from './tools-buckets.js';
|
|
3
|
+
import { tools as dashboardTools } from './tools-dashboards.js';
|
|
4
|
+
import { tools as deviceTools } from './tools-devices.js';
|
|
5
|
+
import { tools as miscTools } from './tools-misc.js';
|
|
6
|
+
import { tools as pluginTools } from './tools-plugins.js';
|
|
7
|
+
import { tools as productTools } from './tools-products.js';
|
|
8
|
+
import { resourceTools } from './tools-resource.js';
|
|
9
|
+
import { tools as storageTools } from './tools-storage.js';
|
|
10
|
+
import { FALLBACK_DOCS, docMimeType, listDocs, readDoc } from '../docs.js';
|
|
11
|
+
import { identity } from '../identity.js';
|
|
11
12
|
import { expandToolRefs, toolName } from './naming.js';
|
|
12
13
|
|
|
13
|
-
/**
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
]
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {object} ToolOptions
|
|
16
|
+
* @property {Record<string, import('./resource-hooks.js').ResourceHooks>} [hooks]
|
|
17
|
+
* The CLI's own hooks on catalog types (see resource-hooks.js).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Tools available to every CLI, declared with their bare names: the
|
|
22
|
+
* generic `resource_*` ones over the catalog, and the few operations that
|
|
23
|
+
* are not CRUD.
|
|
24
|
+
*
|
|
25
|
+
* @param {ToolOptions} [opts]
|
|
26
|
+
*/
|
|
27
|
+
export function genericTools(opts = {}) {
|
|
28
|
+
return [
|
|
29
|
+
...resourceTools(opts),
|
|
30
|
+
...deviceTools,
|
|
31
|
+
...productTools,
|
|
32
|
+
...bucketTools,
|
|
33
|
+
...storageTools,
|
|
34
|
+
...dashboardTools,
|
|
35
|
+
...pluginTools,
|
|
36
|
+
...miscTools,
|
|
37
|
+
];
|
|
38
|
+
}
|
|
25
39
|
|
|
26
|
-
// `
|
|
27
|
-
const PROFILE_EXEMPT = new Set(['
|
|
40
|
+
// `profile_list` is the lookup of the profiles themselves, so it takes none.
|
|
41
|
+
const PROFILE_EXEMPT = new Set(['profile_list']);
|
|
28
42
|
|
|
29
43
|
/**
|
|
30
44
|
* Build the tool list a CLI serves: its own tools on top of the generic
|
|
@@ -38,8 +52,10 @@ const PROFILE_EXEMPT = new Set(['profiles']);
|
|
|
38
52
|
*
|
|
39
53
|
* @param {Array<object>} [extraTools] CLI-specific tools, same shape as the
|
|
40
54
|
* generic ones; `replaces: true` swaps out the generic tool of that name.
|
|
55
|
+
* @param {ToolOptions} [opts]
|
|
41
56
|
*/
|
|
42
|
-
export function buildTools(extraTools = []) {
|
|
57
|
+
export function buildTools(extraTools = [], opts = {}) {
|
|
58
|
+
const generic = genericTools(opts);
|
|
43
59
|
// A CLI may replace a generic tool with its own (same name, flagged
|
|
44
60
|
// `replaces: true`): its platform knows more about that operation.
|
|
45
61
|
// Without the flag a clash is a mistake and fails loudly.
|
|
@@ -48,14 +64,14 @@ export function buildTools(extraTools = []) {
|
|
|
48
64
|
for (const t of extraTools) {
|
|
49
65
|
if (extraNames.has(t.name)) throw new Error(`Duplicate MCP tool name: ${t.name}`);
|
|
50
66
|
extraNames.add(t.name);
|
|
51
|
-
const
|
|
52
|
-
if (t.replaces && !
|
|
67
|
+
const isGeneric = generic.some((g) => g.name === t.name);
|
|
68
|
+
if (t.replaces && !isGeneric) {
|
|
53
69
|
throw new Error(`MCP tool "${t.name}" replaces no generic tool`);
|
|
54
70
|
}
|
|
55
|
-
if (
|
|
56
|
-
if (
|
|
71
|
+
if (isGeneric && !t.replaces) throw new Error(`Duplicate MCP tool name: ${t.name}`);
|
|
72
|
+
if (isGeneric) replaced.add(t.name);
|
|
57
73
|
}
|
|
58
|
-
const declared = [...
|
|
74
|
+
const declared = [...generic.filter((t) => !replaced.has(t.name)), ...extraTools];
|
|
59
75
|
const known = new Set(declared.map((t) => t.name));
|
|
60
76
|
|
|
61
77
|
return declared.map((t) => {
|
|
@@ -64,7 +80,7 @@ export function buildTools(extraTools = []) {
|
|
|
64
80
|
inputSchema.properties = inputSchema.properties || {};
|
|
65
81
|
inputSchema.properties.profile = {
|
|
66
82
|
type: 'string',
|
|
67
|
-
description: `CLI profile
|
|
83
|
+
description: `CLI profile (server) for this call, see ${toolName('profile_list')}. Omit for the default.`,
|
|
68
84
|
};
|
|
69
85
|
}
|
|
70
86
|
const { replaces: _replaces, ...rest } = t;
|
|
@@ -89,3 +105,29 @@ function expandSchema(node, known, where) {
|
|
|
89
105
|
}
|
|
90
106
|
return node;
|
|
91
107
|
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* MCP resources: the documents the server of the default profile lists in
|
|
111
|
+
* its documentation index (see docs.js), each as `<prefix>://<id>`. Listed
|
|
112
|
+
* on every request, so a guide the server adds shows up without a CLI
|
|
113
|
+
* release; the known ones when the server cannot be reached.
|
|
114
|
+
*/
|
|
115
|
+
export async function listResources() {
|
|
116
|
+
const prefix = identity().mcp.toolPrefix;
|
|
117
|
+
const docs = await listDocs().catch(() => FALLBACK_DOCS);
|
|
118
|
+
return docs.map((d) => ({
|
|
119
|
+
uri: `${prefix}://${d.id}`,
|
|
120
|
+
name: d.id,
|
|
121
|
+
title: d.title,
|
|
122
|
+
description: `${d.description || d.title} Also through ${toolName('doc_read')}.`,
|
|
123
|
+
mimeType: docMimeType(d.format),
|
|
124
|
+
}));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Text of the resource `<prefix>://<id>`. */
|
|
128
|
+
export async function readResource(uri) {
|
|
129
|
+
const prefix = `${identity().mcp.toolPrefix}://`;
|
|
130
|
+
if (!uri.startsWith(prefix)) throw new Error(`Unknown resource: ${uri}`);
|
|
131
|
+
const { doc, text } = await readDoc(uri.slice(prefix.length));
|
|
132
|
+
return { mimeType: docMimeType(doc.format), text };
|
|
133
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { CREDENTIAL_DEVICE_TYPES, generateDeviceCredentials } from '../devices.js';
|
|
3
|
+
import { identity } from '../identity.js';
|
|
4
|
+
import { resolveExpiry, validatePermissionTree } from '../tokens.js';
|
|
5
|
+
import { toolName } from './naming.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* What the generic `resource_*` tools do beyond the plain request, for the
|
|
9
|
+
* few types where the CLI knows better than a bare call: generated device
|
|
10
|
+
* credentials, the identity's product template, token expiries written as
|
|
11
|
+
* "30d". Everything else goes to the server as the caller wrote it.
|
|
12
|
+
*
|
|
13
|
+
* A CLI adds its own hooks (its platform's extra cleanup on delete, say)
|
|
14
|
+
* through `runCli({ resourceHooks })`; they run after these.
|
|
15
|
+
*
|
|
16
|
+
* @typedef {object} HookContext
|
|
17
|
+
* @property {string} type
|
|
18
|
+
* @property {string} [parent]
|
|
19
|
+
* @property {string} [id]
|
|
20
|
+
* @property {string | null} [user]
|
|
21
|
+
* @property {boolean} [full] resource_get asked for the whole record.
|
|
22
|
+
* @property {Record<string, any>} state Scratch space shared by the hooks of one call.
|
|
23
|
+
*
|
|
24
|
+
* @typedef {object} ResourceHooks
|
|
25
|
+
* @property {(body: any, ctx: HookContext) => any} [beforeCreate] Returns the body to send.
|
|
26
|
+
* @property {(body: any, ctx: HookContext) => any} [beforeUpdate] Returns the body to send.
|
|
27
|
+
* @property {(result: any, ctx: HookContext) => string | void | Promise<string | void>} [afterCreate]
|
|
28
|
+
* Returns a note added to the tool's answer.
|
|
29
|
+
* @property {(record: any, ctx: HookContext) => any} [afterGet] Returns the record to show.
|
|
30
|
+
* @property {(ctx: HookContext) => string | void | Promise<string | void>} [afterDelete]
|
|
31
|
+
* Returns a note added to the tool's answer.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** Token bodies: expiry as a duration, permission trees checked before sending. */
|
|
35
|
+
function tokenBody(body) {
|
|
36
|
+
validatePermissionTree(body.allow, 'allow');
|
|
37
|
+
validatePermissionTree(body.deny, 'deny');
|
|
38
|
+
if (body.expire === undefined) return body;
|
|
39
|
+
const expire = resolveExpiry(body.expire);
|
|
40
|
+
const { expire: _expire, ...rest } = body;
|
|
41
|
+
return expire === null ? rest : { ...rest, expire };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** @type {Record<string, ResourceHooks>} */
|
|
45
|
+
export const coreHooks = {
|
|
46
|
+
device: {
|
|
47
|
+
// Credentials are stored hashed: generated ones must be shown now
|
|
48
|
+
// or never.
|
|
49
|
+
// The server requires a type; Generic (IOTMP) is the usual one.
|
|
50
|
+
beforeCreate(body, ctx) {
|
|
51
|
+
const type = body.type || 'Generic';
|
|
52
|
+
if (!CREDENTIAL_DEVICE_TYPES.has(type) || body.credentials) return { ...body, type };
|
|
53
|
+
ctx.state.credentials = generateDeviceCredentials();
|
|
54
|
+
return { ...body, type, credentials: ctx.state.credentials };
|
|
55
|
+
},
|
|
56
|
+
afterCreate(_result, ctx) {
|
|
57
|
+
if (!ctx.state.credentials) return;
|
|
58
|
+
return `Generated credentials: ${ctx.state.credentials}\nThe server stores them hashed: this is the only time they can be read. Hand them to whoever flashes the device now.`;
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
product: {
|
|
62
|
+
beforeCreate(body) {
|
|
63
|
+
const template = identity().productTemplate || {};
|
|
64
|
+
const config = { ...structuredClone(template.config || {}), ...(body.config || {}) };
|
|
65
|
+
const profile = body.profile || (template.profile && structuredClone(template.profile));
|
|
66
|
+
return {
|
|
67
|
+
...body,
|
|
68
|
+
name: body.name || body.product,
|
|
69
|
+
description: body.description ?? template.description ?? '',
|
|
70
|
+
enabled: body.enabled ?? true,
|
|
71
|
+
...(Object.keys(config).length ? { config } : {}),
|
|
72
|
+
...(profile ? { profile } : {}),
|
|
73
|
+
};
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
token: {
|
|
77
|
+
beforeCreate: (body) => tokenBody({ enabled: true, ...body }),
|
|
78
|
+
beforeUpdate: (body) => tokenBody(body),
|
|
79
|
+
afterCreate: () =>
|
|
80
|
+
`Keep the access_token now: lists never show it (${toolName('resource_get')} on the token does).`,
|
|
81
|
+
},
|
|
82
|
+
plugin: {
|
|
83
|
+
// A catalog manifest (no `state`: not installed) carries each
|
|
84
|
+
// product's code and console dashboard, thousands of tokens of
|
|
85
|
+
// little use until asked for.
|
|
86
|
+
afterGet(record, ctx) {
|
|
87
|
+
const products = record?.resources?.products;
|
|
88
|
+
if (ctx.full || record?.state !== undefined || !Array.isArray(products)) return record;
|
|
89
|
+
let left = false;
|
|
90
|
+
const trimmed = products.map((product) => {
|
|
91
|
+
const { _resources, ...rest } = product || {};
|
|
92
|
+
const code = rest.profile?.code;
|
|
93
|
+
if (_resources !== undefined) left = true;
|
|
94
|
+
if (!code || typeof code !== 'object' || code.code === undefined) return rest;
|
|
95
|
+
left = true;
|
|
96
|
+
const { code: _source, ...meta } = code;
|
|
97
|
+
return { ...rest, profile: { ...rest.profile, code: meta } };
|
|
98
|
+
});
|
|
99
|
+
if (!left) return record;
|
|
100
|
+
return {
|
|
101
|
+
...record,
|
|
102
|
+
resources: { ...record.resources, products: trimmed },
|
|
103
|
+
omitted: `profile.code.code and _resources (console dashboard) of each product: ${toolName('resource_get')} with full: true includes them`,
|
|
104
|
+
};
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
device_token: {
|
|
108
|
+
beforeCreate(body) {
|
|
109
|
+
if (body.token_expiration === undefined) return body;
|
|
110
|
+
const expiration = resolveExpiry(body.token_expiration);
|
|
111
|
+
const { token_expiration: _e, ...rest } = body;
|
|
112
|
+
return expiration === null ? rest : { ...rest, token_expiration: expiration };
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Chain the core hooks with a CLI's: for each type and stage the CLI's
|
|
119
|
+
* runs on what the core's produced.
|
|
120
|
+
*
|
|
121
|
+
* @param {Record<string, ResourceHooks>} [extra]
|
|
122
|
+
* @returns {Record<string, ResourceHooks>}
|
|
123
|
+
*/
|
|
124
|
+
export function mergeHooks(extra = {}) {
|
|
125
|
+
const merged = { ...coreHooks };
|
|
126
|
+
for (const [type, hooks] of Object.entries(extra)) {
|
|
127
|
+
const base = merged[type] || {};
|
|
128
|
+
/** @type {any} */
|
|
129
|
+
const out = { ...base };
|
|
130
|
+
for (const [stage, fn] of Object.entries(hooks)) {
|
|
131
|
+
const first = base[stage];
|
|
132
|
+
if (!first) {
|
|
133
|
+
out[stage] = fn;
|
|
134
|
+
} else if (stage === 'afterCreate' || stage === 'afterDelete') {
|
|
135
|
+
// Both notes are kept.
|
|
136
|
+
out[stage] = async (a, b) =>
|
|
137
|
+
[await first(a, b), await fn(a, b)].filter(Boolean).join('\n') || undefined;
|
|
138
|
+
} else {
|
|
139
|
+
out[stage] = async (value, ctx) => fn(await first(value, ctx), ctx);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
merged[type] = out;
|
|
143
|
+
}
|
|
144
|
+
return merged;
|
|
145
|
+
}
|
package/lib/mcp/server.js
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
3
3
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
CallToolRequestSchema,
|
|
6
|
+
ListResourcesRequestSchema,
|
|
7
|
+
ListToolsRequestSchema,
|
|
8
|
+
ReadResourceRequestSchema,
|
|
9
|
+
} from '@modelcontextprotocol/sdk/types.js';
|
|
5
10
|
import { classifyError } from '../output.js';
|
|
6
11
|
import { identity } from '../identity.js';
|
|
7
|
-
import { buildTools } from './registry.js';
|
|
12
|
+
import { buildTools, listResources, readResource } from './registry.js';
|
|
8
13
|
import { withProfile } from './helpers.js';
|
|
14
|
+
import { defineCliDocs } from '../docs.js';
|
|
9
15
|
|
|
10
16
|
/**
|
|
11
17
|
* Start the MCP server over stdio. Serves the generic tools plus the ones
|
|
@@ -14,19 +20,30 @@ import { withProfile } from './helpers.js';
|
|
|
14
20
|
*
|
|
15
21
|
* stdout is the protocol channel from here on: diagnostics go to stderr.
|
|
16
22
|
*
|
|
17
|
-
* @param {{
|
|
23
|
+
* @param {{
|
|
24
|
+
* version?: string,
|
|
25
|
+
* tools?: Array<object>,
|
|
26
|
+
* instructions?: string,
|
|
27
|
+
* resourceHooks?: Record<string, import('./resource-hooks.js').ResourceHooks>,
|
|
28
|
+
* docs?: Parameters<typeof import('../docs.js').defineCliDocs>[0],
|
|
29
|
+
* }} [opts]
|
|
18
30
|
* `version` is echoed in the `initialize` response's `serverInfo.version`;
|
|
19
31
|
* callers pass their `package.json#version` so the two never drift.
|
|
20
32
|
* `tools` are CLI-specific tools added to the generic set.
|
|
21
33
|
* `instructions` is the server-level guidance sent to the client.
|
|
34
|
+
* `resourceHooks` are the CLI's hooks on catalog types.
|
|
35
|
+
* `docs` are the guides the CLI ships (see docs.js).
|
|
22
36
|
*/
|
|
23
37
|
export async function startMCPServer({
|
|
24
38
|
version = 'unknown',
|
|
25
39
|
tools: extra = [],
|
|
26
40
|
instructions,
|
|
41
|
+
resourceHooks,
|
|
42
|
+
docs,
|
|
27
43
|
} = {}) {
|
|
28
44
|
const { mcp } = identity();
|
|
29
|
-
|
|
45
|
+
defineCliDocs(docs);
|
|
46
|
+
const tools = buildTools(extra, { hooks: resourceHooks });
|
|
30
47
|
|
|
31
48
|
const server = new Server(
|
|
32
49
|
{
|
|
@@ -34,7 +51,7 @@ export async function startMCPServer({
|
|
|
34
51
|
version,
|
|
35
52
|
},
|
|
36
53
|
{
|
|
37
|
-
capabilities: { tools: {} },
|
|
54
|
+
capabilities: { tools: {}, resources: {} },
|
|
38
55
|
...(instructions ? { instructions } : {}),
|
|
39
56
|
},
|
|
40
57
|
);
|
|
@@ -43,6 +60,18 @@ export async function startMCPServer({
|
|
|
43
60
|
tools: tools.map(({ handler: _handler, ...t }) => t),
|
|
44
61
|
}));
|
|
45
62
|
|
|
63
|
+
server.setRequestHandler(ListResourcesRequestSchema, async () => ({
|
|
64
|
+
resources: await listResources(),
|
|
65
|
+
}));
|
|
66
|
+
|
|
67
|
+
// Resources are read with the default profile: the protocol gives them
|
|
68
|
+
// no arguments to pick another.
|
|
69
|
+
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
70
|
+
const { uri } = request.params;
|
|
71
|
+
const { mimeType, text } = await readResource(uri);
|
|
72
|
+
return { contents: [{ uri, mimeType, text }] };
|
|
73
|
+
});
|
|
74
|
+
|
|
46
75
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
47
76
|
const name = request.params.name;
|
|
48
77
|
const args = request.params.arguments || {};
|
|
@@ -53,6 +82,18 @@ export async function startMCPServer({
|
|
|
53
82
|
isError: true,
|
|
54
83
|
};
|
|
55
84
|
}
|
|
85
|
+
// An argument the tool does not declare is a misspelled one
|
|
86
|
+
// ("aggregation" for "agg"): say so instead of silently ignoring it.
|
|
87
|
+
const known = tool.inputSchema?.properties;
|
|
88
|
+
const unknown = known ? Object.keys(args).filter((k) => !(k in known)) : [];
|
|
89
|
+
if (unknown.length) {
|
|
90
|
+
const message = `Unknown argument${unknown.length > 1 ? 's' : ''} ${unknown.join(', ')}. ${name} takes: ${Object.keys(known).join(', ')}.`;
|
|
91
|
+
return {
|
|
92
|
+
content: [{ type: 'text', text: `Error [input_error]: ${message}` }],
|
|
93
|
+
isError: true,
|
|
94
|
+
_meta: { code: 'input_error', message },
|
|
95
|
+
};
|
|
96
|
+
}
|
|
56
97
|
try {
|
|
57
98
|
return await withProfile(args.profile, () => tool.handler(args));
|
|
58
99
|
} catch (error) {
|
package/lib/mcp/tools-buckets.js
CHANGED
|
@@ -1,33 +1,15 @@
|
|
|
1
1
|
// @ts-check
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
2
|
+
import api from '../api.js';
|
|
3
|
+
import { TIMEOUTS } from '../constants.js';
|
|
4
|
+
import { getBucketData } from '../bucket.js';
|
|
5
|
+
import { apiError, inputError } from '../errors.js';
|
|
6
|
+
import { v1 } from '../paths.js';
|
|
5
7
|
|
|
6
|
-
async function
|
|
7
|
-
|
|
8
|
-
if (!buckets.length) {
|
|
9
|
-
return { content: [{ type: 'text', text: 'No buckets configured' }], isError: false };
|
|
10
|
-
}
|
|
11
|
-
const lines = buckets.map((b) => {
|
|
12
|
-
const enabled = b.enabled === false ? '✗' : '✓';
|
|
13
|
-
const name = b.name && b.name !== b.bucket ? ` (${b.name})` : '';
|
|
14
|
-
const source = b.config?.source ? ` source: ${b.config.source}` : '';
|
|
15
|
-
const product = b.product ? ` [product ${b.product}]` : '';
|
|
16
|
-
const description = b.description ? ` — ${b.description}` : '';
|
|
17
|
-
return `${enabled} ${b.bucket}${name}${product}${source}${description}`;
|
|
18
|
-
});
|
|
19
|
-
return {
|
|
20
|
-
content: [{ type: 'text', text: `${buckets.length} bucket(s):\n${lines.join('\n')}` }],
|
|
21
|
-
isError: false,
|
|
22
|
-
};
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
async function toolBucketRead(args) {
|
|
26
|
-
if (!args.bucket)
|
|
27
|
-
throw inputError(`bucket is required. Use ${toolName('buckets')} to list them.`);
|
|
8
|
+
async function toolBucketDataRead(args) {
|
|
9
|
+
if (!args.bucket) throw inputError('bucket is required');
|
|
28
10
|
const data = await getBucketData({
|
|
29
11
|
bucket: args.bucket,
|
|
30
|
-
device: args.
|
|
12
|
+
device: args.device,
|
|
31
13
|
user: args.user,
|
|
32
14
|
items: args.items || 10,
|
|
33
15
|
sort: args.sort || 'desc',
|
|
@@ -36,96 +18,113 @@ async function toolBucketRead(args) {
|
|
|
36
18
|
max_ts: args.max_ts,
|
|
37
19
|
agg: args.agg,
|
|
38
20
|
agg_type: args.agg_type,
|
|
39
|
-
|
|
21
|
+
tz: args.tz,
|
|
22
|
+
fields: Array.isArray(args.fields) ? args.fields.join(',') : args.fields,
|
|
40
23
|
group_by: args.group_by,
|
|
41
24
|
});
|
|
42
25
|
const text =
|
|
43
26
|
Array.isArray(data) && data.length === 0 ? 'No data' : JSON.stringify(data, null, 2);
|
|
27
|
+
return { content: [{ type: 'text', text }], isError: false };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function toolBucketDataWrite(args) {
|
|
31
|
+
if (!args.bucket) throw inputError('bucket is required');
|
|
32
|
+
if (!args.data || typeof args.data !== 'object') {
|
|
33
|
+
throw inputError('data must be a JSON object (one sample) or an array of them');
|
|
34
|
+
}
|
|
35
|
+
const count = Array.isArray(args.data) ? args.data.length : 1;
|
|
36
|
+
const params = args.ts !== undefined ? { ts: args.ts } : undefined;
|
|
37
|
+
try {
|
|
38
|
+
await api.post(
|
|
39
|
+
`${v1(args.user)}/buckets/${encodeURIComponent(args.bucket)}/data`,
|
|
40
|
+
args.data,
|
|
41
|
+
{ params, timeout: TIMEOUTS.API_LONG_MS },
|
|
42
|
+
);
|
|
43
|
+
} catch (e) {
|
|
44
|
+
throw apiError(e, { notFound: `Bucket not found: ${args.bucket}` });
|
|
45
|
+
}
|
|
44
46
|
return {
|
|
45
|
-
content: [
|
|
47
|
+
content: [
|
|
48
|
+
{
|
|
49
|
+
type: 'text',
|
|
50
|
+
text: `Wrote ${count} sample${count === 1 ? '' : 's'} to bucket ${args.bucket}.`,
|
|
51
|
+
},
|
|
52
|
+
],
|
|
46
53
|
isError: false,
|
|
47
54
|
};
|
|
48
55
|
}
|
|
49
56
|
|
|
57
|
+
const userParam = { type: 'string', description: 'Account to act on (admin only).' };
|
|
58
|
+
|
|
50
59
|
export const tools = [
|
|
51
60
|
{
|
|
52
|
-
name: '
|
|
53
|
-
description:
|
|
54
|
-
'List the data buckets of the account with their enabled flag, name, owning product (for buckets declared in a product profile), data source and description. Use it to find bucket ids for {tool:bucket_read}.',
|
|
55
|
-
inputSchema: {
|
|
56
|
-
type: 'object',
|
|
57
|
-
properties: {
|
|
58
|
-
user: { type: 'string', description: 'API user' },
|
|
59
|
-
},
|
|
60
|
-
required: [],
|
|
61
|
-
},
|
|
62
|
-
handler: toolBuckets,
|
|
63
|
-
},
|
|
64
|
-
{
|
|
65
|
-
name: 'bucket_read',
|
|
66
|
-
description: `Query a time-series bucket.
|
|
61
|
+
name: 'bucket_data_read',
|
|
62
|
+
description: `Query the time series of a bucket (find them with {tool:resource_list}: type "bucket", or "device_bucket" for one device).
|
|
67
63
|
|
|
68
|
-
|
|
69
|
-
-
|
|
70
|
-
- device set: the series a product bucket records for that one device.
|
|
71
|
-
|
|
72
|
-
Don't assume a \`device\` tag exists unless the bucket or product profile defines it.`,
|
|
64
|
+
- Without \`device\`: the whole bucket. A bucket declared in a product profile holds every device of the product interleaved; \`group_by: "device"\` gives one row per device, when samples carry that tag.
|
|
65
|
+
- With \`device\`: the series the bucket keeps for that device.`,
|
|
73
66
|
inputSchema: {
|
|
74
67
|
type: 'object',
|
|
75
68
|
properties: {
|
|
76
|
-
bucket: {
|
|
69
|
+
bucket: { type: 'string', description: 'Bucket id.' },
|
|
70
|
+
device: {
|
|
77
71
|
type: 'string',
|
|
78
72
|
description:
|
|
79
|
-
'
|
|
80
|
-
},
|
|
81
|
-
device_id: {
|
|
82
|
-
type: 'string',
|
|
83
|
-
description:
|
|
84
|
-
'Device ID, to read the series a product bucket keeps for that device. Omit to read the account bucket.',
|
|
73
|
+
'Device id, to read only its records: the bucket needs the `device` tag (product buckets have it).',
|
|
85
74
|
},
|
|
86
75
|
items: {
|
|
87
76
|
type: 'number',
|
|
88
|
-
description:
|
|
89
|
-
'Number of data points to return (default: 10). In multi-device mode set ≥ number of devices.',
|
|
77
|
+
description: 'Samples to return (default 10, at most 1000).',
|
|
90
78
|
},
|
|
91
79
|
minutes: {
|
|
92
80
|
type: 'number',
|
|
93
|
-
description: '
|
|
81
|
+
description: 'Last N minutes (instead of min_ts/max_ts).',
|
|
94
82
|
},
|
|
95
|
-
min_ts: { type: 'number', description: '
|
|
96
|
-
max_ts: {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
sort: {
|
|
101
|
-
type: 'string',
|
|
102
|
-
description:
|
|
103
|
-
'Sort order by timestamp: "asc" or "desc" (default: "desc"). Does NOT sort by metric — do that client-side.',
|
|
104
|
-
},
|
|
105
|
-
agg: {
|
|
83
|
+
min_ts: { type: 'number', description: 'From (unix ms).' },
|
|
84
|
+
max_ts: { type: 'number', description: 'To (unix ms, 0 = now).' },
|
|
85
|
+
sort: { type: 'string', description: '"desc" (default) or "asc", by time.' },
|
|
86
|
+
agg: { type: 'string', description: 'Aggregation window: "5m", "1h"…' },
|
|
87
|
+
agg_type: {
|
|
106
88
|
type: 'string',
|
|
107
89
|
description:
|
|
108
|
-
'
|
|
90
|
+
'Reducer: mean, max, min, sum, count, first, last, mode, median, spread, stddev.',
|
|
109
91
|
},
|
|
110
|
-
|
|
92
|
+
tz: {
|
|
111
93
|
type: 'string',
|
|
112
94
|
description:
|
|
113
|
-
'
|
|
95
|
+
'IANA time zone for the aggregation windows, e.g. "Europe/Madrid": "1d" then cuts at local midnight (default UTC).',
|
|
114
96
|
},
|
|
115
97
|
fields: {
|
|
116
|
-
type: 'string',
|
|
98
|
+
type: ['string', 'array'],
|
|
99
|
+
items: { type: 'string' },
|
|
117
100
|
description:
|
|
118
|
-
'
|
|
101
|
+
'Fields to return: comma-separated or an array. Without fields, an aggregation covers numeric fields only (those of the latest record in the range); name string fields explicitly to read them with last, first, mode or count.',
|
|
119
102
|
},
|
|
120
|
-
group_by: {
|
|
121
|
-
|
|
103
|
+
group_by: { type: 'string', description: 'Group key, e.g. "device".' },
|
|
104
|
+
user: userParam,
|
|
105
|
+
},
|
|
106
|
+
required: ['bucket'],
|
|
107
|
+
},
|
|
108
|
+
handler: toolBucketDataRead,
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
name: 'bucket_data_write',
|
|
112
|
+
description:
|
|
113
|
+
'Write samples to a bucket whose data source accepts API writes: one object, stored with the current time or `ts`, or an array of objects, each with its own numeric `ts` (unix ms; MongoDB and InfluxDB buckets). Servers limit how often a bucket can be written (429 when exceeded): for long histories, import a CSV ({tool:resource_call} action "import" on the bucket).',
|
|
114
|
+
inputSchema: {
|
|
115
|
+
type: 'object',
|
|
116
|
+
properties: {
|
|
117
|
+
bucket: { type: 'string', description: 'Bucket id.' },
|
|
118
|
+
data: {
|
|
119
|
+
type: ['object', 'array'],
|
|
122
120
|
description:
|
|
123
|
-
'
|
|
121
|
+
'Sample fields, e.g. {"temperature": 21.5}, or an array of samples with `ts`.',
|
|
124
122
|
},
|
|
125
|
-
|
|
123
|
+
ts: { type: 'number', description: 'Timestamp of the sample (unix ms).' },
|
|
124
|
+
user: userParam,
|
|
126
125
|
},
|
|
127
|
-
required: ['bucket'],
|
|
126
|
+
required: ['bucket', 'data'],
|
|
128
127
|
},
|
|
129
|
-
handler:
|
|
128
|
+
handler: toolBucketDataWrite,
|
|
130
129
|
},
|
|
131
130
|
];
|