@thinger-io/cli-core 0.4.2 → 0.5.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +195 -0
  2. package/README.md +82 -31
  3. package/lib/bucket-export.js +91 -0
  4. package/lib/cli.js +106 -50
  5. package/lib/commands/_shared.js +89 -1
  6. package/lib/commands/api.js +90 -0
  7. package/lib/commands/arguments.js +62 -0
  8. package/lib/commands/bucket.js +52 -44
  9. package/lib/commands/dashboard.js +5 -2
  10. package/lib/commands/data.js +539 -0
  11. package/lib/commands/device.js +128 -241
  12. package/lib/commands/grammar.js +279 -0
  13. package/lib/commands/product.js +104 -214
  14. package/lib/commands/profile.js +1 -0
  15. package/lib/commands/resources.js +975 -0
  16. package/lib/dashboard-init.js +4 -3
  17. package/lib/dashboard.js +10 -2
  18. package/lib/mcp/catalog.js +301 -63
  19. package/lib/mcp/core-types.js +36 -0
  20. package/lib/mcp/helpers.js +23 -2
  21. package/lib/mcp/naming.js +20 -0
  22. package/lib/mcp/registry.js +18 -67
  23. package/lib/mcp/resource-hooks.js +19 -5
  24. package/lib/mcp/server.js +43 -11
  25. package/lib/mcp/tools-resource.js +1232 -330
  26. package/lib/mcp/types/bucket-data.js +151 -0
  27. package/lib/mcp/{tools-dashboards.js → types/dashboard-apps.js} +99 -37
  28. package/lib/mcp/types/device-resource.js +117 -0
  29. package/lib/mcp/types/doc.js +35 -0
  30. package/lib/mcp/types/plugin-call.js +75 -0
  31. package/lib/mcp/types/product-api.js +44 -0
  32. package/lib/mcp/types/profile.js +37 -0
  33. package/lib/mcp/types/storage-file.js +170 -0
  34. package/lib/openapi.js +18 -0
  35. package/lib/product-apply.js +121 -0
  36. package/lib/raw-api.js +111 -0
  37. package/lib/storage.js +41 -13
  38. package/lib/tokens.js +16 -2
  39. package/package.json +1 -1
  40. package/lib/mcp/tools-buckets.js +0 -130
  41. package/lib/mcp/tools-devices.js +0 -134
  42. package/lib/mcp/tools-misc.js +0 -48
  43. package/lib/mcp/tools-plugins.js +0 -63
  44. package/lib/mcp/tools-products.js +0 -52
  45. package/lib/mcp/tools-storage.js +0 -173
@@ -0,0 +1,36 @@
1
+ // @ts-check
2
+ import { bucketData } from './types/bucket-data.js';
3
+ import { dashboardActions } from './types/dashboard-apps.js';
4
+ import { deviceResource, overview } from './types/device-resource.js';
5
+ import { doc } from './types/doc.js';
6
+ import { pluginCall } from './types/plugin-call.js';
7
+ import { apiCall } from './types/product-api.js';
8
+ import { profile } from './types/profile.js';
9
+ import { storageFile } from './types/storage-file.js';
10
+
11
+ /**
12
+ * What every CLI on the core serves next to the catalog, through the same
13
+ * `resource_*` tools: data and files as types (a bucket's time series, a
14
+ * storage's files, a connected device's resources), the guides and the
15
+ * profiles, and the actions on catalog types that are more than one
16
+ * request (or run on this machine). They take the shape of the types and
17
+ * actions a CLI adds (tools-resource.js), with the server operations each
18
+ * runs (`ops`, `op`), or `local` when it runs here.
19
+ */
20
+
21
+ /** @type {Record<string, import('./tools-resource.js').LocalType>} */
22
+ export const CORE_TYPES = {
23
+ bucket_data: bucketData,
24
+ storage_file: storageFile,
25
+ device_resource: deviceResource,
26
+ doc,
27
+ profile,
28
+ };
29
+
30
+ /** @type {Record<string, Record<string, import('./tools-resource.js').LocalAction>>} */
31
+ export const CORE_ACTIONS = {
32
+ device: { overview },
33
+ product_profile_api: { call: apiCall },
34
+ plugin: { call: pluginCall },
35
+ dashboard: dashboardActions,
36
+ };
@@ -1,7 +1,28 @@
1
1
  // @ts-check
2
2
  import { getProfile, withActiveProfile } from '../config.js';
3
3
  import { inputError } from '../errors.js';
4
- import { toolName } from './naming.js';
4
+ import { opRef } from './naming.js';
5
+
6
+ /**
7
+ * Key of the structured answer a tool result carries next to its text, for
8
+ * the CLI commands that run the same handlers (`--json`). A symbol: it
9
+ * never reaches the MCP client.
10
+ */
11
+ export const DATA = Symbol('data');
12
+
13
+ /**
14
+ * Set on the arguments of a call a CLI command makes (runTool): the
15
+ * answer is read by a person, so records go without the `ids` an agent
16
+ * passes back.
17
+ */
18
+ export const CLI = Symbol('cli');
19
+
20
+ /** A text tool result, with `data` for the CLI's --json when given. */
21
+ export const textResult = (t, data) => ({
22
+ content: [{ type: 'text', text: t }],
23
+ isError: false,
24
+ ...(data === undefined ? {} : { [DATA]: data }),
25
+ });
5
26
 
6
27
  /** Run `fn` against `profile` (when supplied) so callers can target any
7
28
  * configured environment per tool call without persisting the change.
@@ -11,7 +32,7 @@ export async function withProfile(profile, fn) {
11
32
  if (!profile) return fn();
12
33
  if (!getProfile(profile)) {
13
34
  throw inputError(
14
- `Unknown profile: ${profile}. Use ${toolName('profile_list')} to list available ones.`,
35
+ `Unknown profile: ${profile}. ${opRef('profile', 'list')} lists the available ones.`,
15
36
  );
16
37
  }
17
38
  return withActiveProfile(profile, fn);
package/lib/mcp/naming.js CHANGED
@@ -9,6 +9,10 @@ import { identity } from '../identity.js';
9
9
  * `{family:token}` (→ `thinger_token_*`); the registry expands both once
10
10
  * the identity is known. Handlers building messages at call time use
11
11
  * `toolName()` directly.
12
+ *
13
+ * The tools are the eight `resource_*` ones: an operation is a type and a
14
+ * verb or action, written `{op:<type>.<verb|action>}` in the texts (the
15
+ * resource tools expand it, checking it exists) and `opRef()` in code.
12
16
  */
13
17
 
14
18
  const TOOL_REF = /\{(tool|family):([a-z0-9_]+)\}/g;
@@ -42,3 +46,19 @@ export function expandToolRefs(text, known, where) {
42
46
  return toolName(base);
43
47
  });
44
48
  }
49
+
50
+ /** Verbs with a tool of their own; anything else is an action of resource_call. */
51
+ const VERB_TOOLS = new Set(['describe', 'list', 'get', 'create', 'update', 'delete', 'clone']);
52
+
53
+ /**
54
+ * How an operation is called: `<prefix>_resource_list type "bucket_data"`,
55
+ * `<prefix>_resource_call type "device" action "send"`.
56
+ *
57
+ * @param {string} type
58
+ * @param {string} op A verb or an action.
59
+ */
60
+ export function opRef(type, op) {
61
+ return VERB_TOOLS.has(op)
62
+ ? `${toolName(`resource_${op}`)} type "${type}"`
63
+ : `${toolName('resource_call')} type "${type}" action "${op}"`;
64
+ }
@@ -1,15 +1,8 @@
1
1
  // @ts-check
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
2
  import { resourceTools } from './tools-resource.js';
9
- import { tools as storageTools } from './tools-storage.js';
10
3
  import { docMimeType, listDocs, readDoc } from '../docs.js';
11
4
  import { identity } from '../identity.js';
12
- import { expandToolRefs, toolName } from './naming.js';
5
+ import { expandToolRefs, opRef, toolName } from './naming.js';
13
6
 
14
7
  /**
15
8
  * @typedef {object} ToolOptions
@@ -19,77 +12,35 @@ import { expandToolRefs, toolName } from './naming.js';
19
12
  * Resource types the CLI adds, served by the same `resource_*` tools.
20
13
  * @property {Record<string, Record<string, import('./tools-resource.js').LocalAction>>} [actions]
21
14
  * Actions the CLI adds to resource types (the server's or its own).
15
+ * @property {boolean} [readOnly] Serve only what reads (`mcp --read-only`).
22
16
  */
23
17
 
24
18
  /**
25
- * Tools available to every CLI, declared with their bare names: the
26
- * generic `resource_*` ones over the catalog, and the few operations that
27
- * are not CRUD.
28
- *
29
- * @param {ToolOptions} [opts]
30
- */
31
- export function genericTools(opts = {}) {
32
- return [
33
- ...resourceTools(opts),
34
- ...deviceTools,
35
- ...productTools,
36
- ...bucketTools,
37
- ...storageTools,
38
- ...dashboardTools,
39
- ...pluginTools,
40
- ...miscTools,
41
- ];
42
- }
43
-
44
- // `profile_list` is the lookup of the profiles themselves, so it takes none.
45
- const PROFILE_EXEMPT = new Set(['profile_list']);
46
-
47
- /**
48
- * Build the tool list a CLI serves: its own tools on top of the generic
49
- * ones, every name prefixed with the identity's tool prefix, `{tool:x}`
50
- * references in descriptions expanded, and the optional `profile`
51
- * parameter injected so callers can target any configured environment
52
- * per call without changing the saved default.
19
+ * Build the tool list a CLI serves: the eight `resource_*` tools over the
20
+ * catalog, the core's types and the CLI's, every name prefixed with the
21
+ * identity's tool prefix, `{tool:x}` references in descriptions expanded,
22
+ * and the optional `profile` parameter injected so callers can target any
23
+ * configured environment per call without changing the saved default.
53
24
  *
54
25
  * Returns fresh objects: the declarations stay untouched, so building
55
- * twice (tests, a CLI adding tools) never double-prefixes anything.
26
+ * twice (tests) never double-prefixes anything.
56
27
  *
57
- * @param {Array<object>} [extraTools] CLI-specific tools, same shape as the
58
- * generic ones; `replaces: true` swaps out the generic tool of that name.
59
28
  * @param {ToolOptions} [opts]
29
+ * @returns {Array<Record<string, any>>}
60
30
  */
61
- export function buildTools(extraTools = [], opts = {}) {
62
- const generic = genericTools(opts);
63
- // A CLI may replace a generic tool with its own (same name, flagged
64
- // `replaces: true`): its platform knows more about that operation.
65
- // Without the flag a clash is a mistake and fails loudly.
66
- const replaced = new Set();
67
- const extraNames = new Set();
68
- for (const t of extraTools) {
69
- if (extraNames.has(t.name)) throw new Error(`Duplicate MCP tool name: ${t.name}`);
70
- extraNames.add(t.name);
71
- const isGeneric = generic.some((g) => g.name === t.name);
72
- if (t.replaces && !isGeneric) {
73
- throw new Error(`MCP tool "${t.name}" replaces no generic tool`);
74
- }
75
- if (isGeneric && !t.replaces) throw new Error(`Duplicate MCP tool name: ${t.name}`);
76
- if (isGeneric) replaced.add(t.name);
77
- }
78
- const declared = [...generic.filter((t) => !replaced.has(t.name)), ...extraTools];
31
+ export function buildTools(opts = {}) {
32
+ const declared = resourceTools(opts);
79
33
  const known = new Set(declared.map((t) => t.name));
80
34
 
81
35
  return declared.map((t) => {
82
36
  const inputSchema = expandSchema(structuredClone(t.inputSchema), known, t.name);
83
- if (!PROFILE_EXEMPT.has(t.name)) {
84
- inputSchema.properties = inputSchema.properties || {};
85
- inputSchema.properties.profile = {
86
- type: 'string',
87
- description: `CLI profile (server) for this call, see ${toolName('profile_list')}. Omit for the default.`,
88
- };
89
- }
90
- const { replaces: _replaces, ...rest } = t;
37
+ inputSchema.properties = inputSchema.properties || {};
38
+ inputSchema.properties.profile = {
39
+ type: 'string',
40
+ description: `CLI profile (server) for this call; ${opRef('profile', 'list')} lists them. Omit for the default.`,
41
+ };
91
42
  return {
92
- ...rest,
43
+ ...t,
93
44
  name: toolName(t.name),
94
45
  description: expandToolRefs(t.description, known, t.name),
95
46
  inputSchema,
@@ -121,7 +72,7 @@ export async function listResources() {
121
72
  uri: `${prefix}://${d.id}`,
122
73
  name: d.id,
123
74
  title: d.title,
124
- description: `${d.description || d.title} Also through ${toolName('doc_read')}.`,
75
+ description: `${d.description || d.title} Also through ${opRef('doc', 'get')}.`,
125
76
  mimeType: docMimeType(d.format),
126
77
  }));
127
78
  }
@@ -1,7 +1,7 @@
1
1
  // @ts-check
2
2
  import { CREDENTIAL_DEVICE_TYPES, generateDeviceCredentials } from '../devices.js';
3
3
  import { identity } from '../identity.js';
4
- import { resolveExpiry, validatePermissionTree } from '../tokens.js';
4
+ import { resolveExpiry, resolveExpiryMs, validatePermissionTree } from '../tokens.js';
5
5
  import { toolName } from './naming.js';
6
6
 
7
7
  /**
@@ -15,10 +15,13 @@ import { toolName } from './naming.js';
15
15
  *
16
16
  * @typedef {object} HookContext
17
17
  * @property {string} type
18
- * @property {string} [parent]
19
- * @property {string} [id]
18
+ * @property {Record<string, string>} ids Every id of the call, by name.
19
+ * @property {string} [parent] The parents' ids, outermost first, joined with "/".
20
+ * @property {string} [id] The record's own id.
20
21
  * @property {string | null} [user]
21
22
  * @property {boolean} [full] resource_get asked for the whole record.
23
+ * @property {boolean} [merge] resource_update patches (beforeUpdate gets the patch).
24
+
22
25
  * @property {Record<string, any>} state Scratch space shared by the hooks of one call.
23
26
  *
24
27
  * @typedef {object} ResourceHooks
@@ -29,14 +32,16 @@ import { toolName } from './naming.js';
29
32
  * @property {(record: any, ctx: HookContext) => any} [afterGet] Returns the record to show.
30
33
  * @property {(ctx: HookContext) => string | void | Promise<string | void>} [afterDelete]
31
34
  * Returns a note added to the tool's answer.
35
+ * @property {() => any} [describe] What resource_describe adds about the type, under
36
+ * `cli`: the rules the CLI's other hooks enforce, say.
32
37
  */
33
38
 
34
- /** Token bodies: expiry as a duration, permission trees checked before sending. */
39
+ /** Token bodies: expiry as a duration (or unix ms), permission trees checked before sending. */
35
40
  function tokenBody(body) {
36
41
  validatePermissionTree(body.allow, 'allow');
37
42
  validatePermissionTree(body.deny, 'deny');
38
43
  if (body.expire === undefined) return body;
39
- const expire = resolveExpiry(body.expire);
44
+ const expire = resolveExpiryMs(body.expire);
40
45
  const { expire: _expire, ...rest } = body;
41
46
  return expire === null ? rest : { ...rest, expire };
42
47
  }
@@ -131,6 +136,15 @@ export function mergeHooks(extra = {}) {
131
136
  const first = base[stage];
132
137
  if (!first) {
133
138
  out[stage] = fn;
139
+ } else if (stage === 'describe') {
140
+ // Both are shown, the CLI's last.
141
+ const [one, two] = /** @type {Array<() => any>} */ ([first, fn]);
142
+ out[stage] = async () => {
143
+ const [a, b] = [await one(), await two()];
144
+ return a && b && typeof a === 'object' && typeof b === 'object'
145
+ ? { ...a, ...b }
146
+ : (b ?? a);
147
+ };
134
148
  } else if (stage === 'afterCreate' || stage === 'afterDelete') {
135
149
  // Both notes are kept.
136
150
  out[stage] = async (a, b) =>
package/lib/mcp/server.js CHANGED
@@ -13,45 +13,50 @@ import { buildTools, listResources, readResource } from './registry.js';
13
13
  import { withProfile } from './helpers.js';
14
14
  import { defineCliDocs } from '../docs.js';
15
15
 
16
+ /** What a tool declares that the protocol lists. */
17
+ const LISTED = ['name', 'title', 'description', 'inputSchema', 'annotations'];
18
+
16
19
  /**
17
- * Start the MCP server over stdio. Serves the generic tools plus the ones
18
- * the CLI adds; every tool takes an optional `profile` (and `user`, for
19
- * admin impersonation, where it applies).
20
+ * Start the MCP server over stdio. Serves the eight `resource_*` tools over
21
+ * the catalog, the core's types and the CLI's; every tool takes an
22
+ * optional `profile` (and `user`, for admin impersonation, where it
23
+ * applies).
20
24
  *
21
25
  * stdout is the protocol channel from here on: diagnostics go to stderr.
22
26
  *
23
27
  * @param {{
24
28
  * version?: string,
25
- * tools?: Array<object>,
26
29
  * instructions?: string,
27
30
  * resourceHooks?: Record<string, import('./resource-hooks.js').ResourceHooks>,
28
31
  * resourceTypes?: Record<string, import('./tools-resource.js').LocalType>,
29
32
  * resourceActions?: Record<string, Record<string, import('./tools-resource.js').LocalAction>>,
30
33
  * docs?: Parameters<typeof import('../docs.js').defineCliDocs>[0],
34
+ * readOnly?: boolean,
31
35
  * }} [opts]
32
36
  * `version` is echoed in the `initialize` response's `serverInfo.version`;
33
37
  * callers pass their `package.json#version` so the two never drift.
34
- * `tools` are CLI-specific tools added to the generic set.
35
38
  * `instructions` is the server-level guidance sent to the client.
36
39
  * `resourceHooks` are the CLI's hooks on catalog types; `resourceTypes`
37
40
  * and `resourceActions` the types and actions it adds to the catalog.
38
- * `docs` are the guides the CLI ships (see docs.js).
41
+ * `docs` are the guides the CLI ships (see docs.js). `readOnly` serves
42
+ * only what reads: describe, list, get, and the read-only actions.
39
43
  */
40
44
  export async function startMCPServer({
41
45
  version = 'unknown',
42
- tools: extra = [],
43
46
  instructions,
44
47
  resourceHooks,
45
48
  resourceTypes,
46
49
  resourceActions,
47
50
  docs,
51
+ readOnly = false,
48
52
  } = {}) {
49
53
  const { mcp } = identity();
50
54
  defineCliDocs(docs);
51
- const tools = buildTools(extra, {
55
+ const tools = buildTools({
52
56
  hooks: resourceHooks,
53
57
  types: resourceTypes,
54
58
  actions: resourceActions,
59
+ readOnly,
55
60
  });
56
61
 
57
62
  const server = new Server(
@@ -66,9 +71,33 @@ export async function startMCPServer({
66
71
  );
67
72
 
68
73
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
69
- tools: tools.map(({ handler: _handler, ...t }) => t),
74
+ tools: tools.map((t) =>
75
+ Object.fromEntries(LISTED.filter((k) => t[k] !== undefined).map((k) => [k, t[k]])),
76
+ ),
70
77
  }));
71
78
 
79
+ /**
80
+ * Ask the user to confirm an action with a high impact, when the client
81
+ * can show a form (elicitation): the agent cannot answer for them.
82
+ */
83
+ const elicit = async (message) => {
84
+ const result = await server.elicitInput({
85
+ message,
86
+ requestedSchema: {
87
+ type: 'object',
88
+ properties: {
89
+ confirm: {
90
+ type: 'boolean',
91
+ title: 'Do it',
92
+ description: 'Run it as described above.',
93
+ },
94
+ },
95
+ required: ['confirm'],
96
+ },
97
+ });
98
+ return result.action === 'accept' && result.content?.confirm === true;
99
+ };
100
+
72
101
  server.setRequestHandler(ListResourcesRequestSchema, async () => ({
73
102
  resources: await listResources(),
74
103
  }));
@@ -96,15 +125,18 @@ export async function startMCPServer({
96
125
  const known = tool.inputSchema?.properties;
97
126
  const unknown = known ? Object.keys(args).filter((k) => !(k in known)) : [];
98
127
  if (unknown.length) {
99
- const message = `Unknown argument${unknown.length > 1 ? 's' : ''} ${unknown.join(', ')}. ${name} takes: ${Object.keys(known).join(', ')}.`;
128
+ // A filter or body key written at the top: say where it goes.
129
+ const hint = tool.explain?.(args, unknown);
130
+ const message = `Unknown argument${unknown.length > 1 ? 's' : ''} ${unknown.join(', ')}. ${name} takes: ${Object.keys(known).join(', ')}.${hint ? ` ${hint}.` : ''}`;
100
131
  return {
101
132
  content: [{ type: 'text', text: `Error [input_error]: ${message}` }],
102
133
  isError: true,
103
134
  _meta: { code: 'input_error', message },
104
135
  };
105
136
  }
137
+ const extra = server.getClientCapabilities()?.elicitation ? { elicit } : {};
106
138
  try {
107
- return await withProfile(args.profile, () => tool.handler(args));
139
+ return await withProfile(args.profile, () => tool.handler(args, extra));
108
140
  } catch (error) {
109
141
  // Surface the same {message, code} pair the CLI emits in JSON
110
142
  // mode so MCP clients can pattern-match on `[code]` instead of