@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.
@@ -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('profiles')} to list available ones.`,
14
+ `Unknown profile: ${profile}. Use ${toolName('profile_list')} to list available ones.`,
15
15
  );
16
16
  }
17
17
  return withActiveProfile(profile, fn);
@@ -1,30 +1,44 @@
1
1
  // @ts-check
2
- import { tools as devicesTools } from './tools-devices.js';
3
- import { tools as resourcesTools } from './tools-resources.js';
4
- import { tools as propertiesTools } from './tools-properties.js';
5
- import { tools as productsTools } from './tools-products.js';
6
- import { tools as profilesTools } from './tools-profiles.js';
7
- import { tools as bucketsTools } from './tools-buckets.js';
8
- import { tools as productProfileTools } from './tools-profile.js';
9
- import { tools as tokensTools } from './tools-tokens.js';
10
- import { tools as alarmsTools } from './tools-alarms.js';
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
- /** Tools available to every CLI, declared with their bare names. */
14
- export const genericTools = [
15
- ...devicesTools,
16
- ...resourcesTools,
17
- ...propertiesTools,
18
- ...productsTools,
19
- ...profilesTools,
20
- ...bucketsTools,
21
- ...productProfileTools,
22
- ...tokensTools,
23
- ...alarmsTools,
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
- // `profiles` is the lookup of the profiles themselves, so it takes none.
27
- const PROFILE_EXEMPT = new Set(['profiles']);
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 generic = genericTools.some((g) => g.name === t.name);
52
- if (t.replaces && !generic) {
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 (generic && !t.replaces) throw new Error(`Duplicate MCP tool name: ${t.name}`);
56
- if (generic) replaced.add(t.name);
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 = [...genericTools.filter((t) => !replaced.has(t.name)), ...extraTools];
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 to use for this call (see ${toolName('profiles')}). Omit for the default.`,
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 { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
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 {{ version?: string, tools?: Array<object>, instructions?: string }} [opts]
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
- const tools = buildTools(extra);
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) {
@@ -1,33 +1,15 @@
1
1
  // @ts-check
2
- import { getBucketData, getBuckets } from '../bucket.js';
3
- import { inputError } from '../errors.js';
4
- import { toolName } from './naming.js';
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 toolBuckets(args) {
7
- const buckets = await getBuckets(args.user);
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.device_id,
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
- fields: args.fields,
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: [{ type: 'text', text }],
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: 'buckets',
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
- Modes:
69
- - no device: the account bucket (see {tool:buckets}). For a bucket declared in a product profile this holds the samples of every device of the product, interleaved; pass \`group_by=device\` (when samples are tagged with \`device\`) to get one row per device.
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
- 'Bucket ID, as listed by {tool:buckets} or declared in a product profile.',
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: 'Get data from the last N minutes. Alternative to min_ts/max_ts.',
81
+ description: 'Last N minutes (instead of min_ts/max_ts).',
94
82
  },
95
- min_ts: { type: 'number', description: 'Minimum timestamp in milliseconds' },
96
- max_ts: {
97
- type: 'number',
98
- description: 'Maximum timestamp in milliseconds (0 = now)',
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
- 'Time-window size for windowed aggregation: "5m", "10m", "1h", "6h". Only meaningful with a specific device. For a per-device single value across the whole range, omit `agg` and pass `agg_type` alone.',
90
+ 'Reducer: mean, max, min, sum, count, first, last, mode, median, spread, stddev.',
109
91
  },
110
- agg_type: {
92
+ tz: {
111
93
  type: 'string',
112
94
  description:
113
- 'Reducer applied to each group/window. Supported: "mean", "max", "min", "sum", "count", "first", "last", "median", "spread", "stddev".',
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
- 'Comma-separated fields to return (e.g., "temperature,humidity"). Narrower queries are cheaper.',
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
- type: 'string',
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
- 'Optional group key (e.g. "device" for a one-row-per-device fleet view). Omit for raw samples — custom buckets may not carry a "device" tag.',
121
+ 'Sample fields, e.g. {"temperature": 21.5}, or an array of samples with `ts`.',
124
122
  },
125
- user: { type: 'string', description: 'API user' },
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: toolBucketRead,
128
+ handler: toolBucketDataWrite,
130
129
  },
131
130
  ];