@thinger-io/cli-core 0.2.1 → 0.3.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 +20 -0
- package/README.md +12 -0
- package/lib/cli.js +9 -1
- package/lib/mcp/catalog.js +10 -0
- package/lib/mcp/registry.js +4 -0
- package/lib/mcp/server.js +11 -2
- package/lib/mcp/tools-resource.js +385 -48
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.3.0] - 2026-10-14
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- `runCli({ resourceTypes, resourceActions })`: a CLI adds its platform's
|
|
8
|
+
own resource types (functions instead of a REST path) and actions on the
|
|
9
|
+
server's types, served by the same `resource_*` tools: listed in
|
|
10
|
+
`resource_describe`, searched and paged by `resource_list`, their parent
|
|
11
|
+
and id checked like the server's. Clashes with the catalog fail at
|
|
12
|
+
startup. ThinRemote serves its files, scripts, metrics, log sources and
|
|
13
|
+
playbooks this way.
|
|
14
|
+
- `alarm_instance` action `stats`: how many instances there are in each
|
|
15
|
+
state.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- `resource_list` pages lists answered whole from any `index`, not only
|
|
20
|
+
when they are longer than a page.
|
|
21
|
+
- `resource_describe` says which server actions take no id.
|
|
22
|
+
|
|
3
23
|
## [0.2.1] - 2026-10-12
|
|
4
24
|
|
|
5
25
|
### Changed
|
package/README.md
CHANGED
|
@@ -70,6 +70,18 @@ servers.
|
|
|
70
70
|
the same name.
|
|
71
71
|
- `resourceHooks` — the CLI's hooks on catalog types (`{ product: {
|
|
72
72
|
afterDelete } }`), run after the core's: see `mcp/resource-hooks.js`.
|
|
73
|
+
- `resourceTypes` — resource types of the CLI's own platform, served by the
|
|
74
|
+
same `resource_*` tools as the server's: instead of a REST path each has
|
|
75
|
+
functions for the verbs it supports (`list`, `get`, `create`, `update`,
|
|
76
|
+
`delete`), its `parent` and `id`, a `schema` and `notes` for
|
|
77
|
+
`resource_describe`, and `actions` for `resource_call` (`on: 'item'`,
|
|
78
|
+
`'collection'` or `'either'`). The core checks the parent and id, and
|
|
79
|
+
searches and pages what `list` returns. See `LocalType` in
|
|
80
|
+
`mcp/tools-resource.js`.
|
|
81
|
+
- `resourceActions` — actions the CLI adds to the server's types
|
|
82
|
+
(`{ device: { reboot: { description, run } } }`), run with
|
|
83
|
+
`resource_call` like the server's. A type or action that clashes with the
|
|
84
|
+
catalog fails at startup.
|
|
73
85
|
- `mcpInstructions` — server-level guidance sent to MCP clients.
|
|
74
86
|
|
|
75
87
|
## MCP tools
|
package/lib/cli.js
CHANGED
|
@@ -23,13 +23,17 @@ import { registerBucketCommands } from './commands/bucket.js';
|
|
|
23
23
|
* mcpTools?: Array<object>,
|
|
24
24
|
* mcpInstructions?: string,
|
|
25
25
|
* resourceHooks?: Record<string, import('./mcp/resource-hooks.js').ResourceHooks>,
|
|
26
|
+
* resourceTypes?: Record<string, import('./mcp/tools-resource.js').LocalType>,
|
|
27
|
+
* resourceActions?: Record<string, Record<string, import('./mcp/tools-resource.js').LocalAction>>,
|
|
26
28
|
* docs?: Parameters<typeof import('./docs.js').defineCliDocs>[0],
|
|
27
29
|
* }} opts
|
|
28
30
|
* `register` adds the CLI's own commands, at the top level or inside the
|
|
29
31
|
* generic groups. `onNoCommand` runs when the CLI is called without
|
|
30
32
|
* arguments (default: print the help). `mcpTools` are the CLI's own MCP
|
|
31
33
|
* tools (see buildTools), and `resourceHooks` its hooks on the
|
|
32
|
-
* catalog types the generic `resource_*` tools serve
|
|
34
|
+
* catalog types the generic `resource_*` tools serve; `resourceTypes` and
|
|
35
|
+
* `resourceActions` are types and actions it adds to that catalog (its
|
|
36
|
+
* platform's own things, served by the same tools). `docs` are guides
|
|
33
37
|
* the CLI ships, served by `doc_read` and as MCP resources next to the
|
|
34
38
|
* server's documentation.
|
|
35
39
|
*/
|
|
@@ -41,6 +45,8 @@ export async function runCli({
|
|
|
41
45
|
mcpTools = [],
|
|
42
46
|
mcpInstructions,
|
|
43
47
|
resourceHooks,
|
|
48
|
+
resourceTypes,
|
|
49
|
+
resourceActions,
|
|
44
50
|
docs,
|
|
45
51
|
}) {
|
|
46
52
|
const id = identity();
|
|
@@ -91,6 +97,8 @@ export async function runCli({
|
|
|
91
97
|
tools: mcpTools,
|
|
92
98
|
instructions: mcpInstructions,
|
|
93
99
|
resourceHooks,
|
|
100
|
+
resourceTypes,
|
|
101
|
+
resourceActions,
|
|
94
102
|
docs,
|
|
95
103
|
});
|
|
96
104
|
} catch (error) {
|
package/lib/mcp/catalog.js
CHANGED
|
@@ -339,6 +339,16 @@ export const CATALOG = {
|
|
|
339
339
|
delete: 'Alarm_DeleteInstance',
|
|
340
340
|
projects: 'Alarm_UpdateInstanceProjects',
|
|
341
341
|
},
|
|
342
|
+
actions: {
|
|
343
|
+
stats: {
|
|
344
|
+
op: 'Alarm_ReadInstanceStats',
|
|
345
|
+
method: 'get',
|
|
346
|
+
path: 'stats',
|
|
347
|
+
on: 'collection',
|
|
348
|
+
description:
|
|
349
|
+
'How many instances there are in each state: the health of the account at a glance, without listing them.',
|
|
350
|
+
},
|
|
351
|
+
},
|
|
342
352
|
notes: 'States: 0 none, 1 active, 2 acknowledged, 3 latched, 4 shelved, 5 cleared; severities: 0 none, 1 low, 2 medium, 3 high, 4 critical. Acknowledge with update {state: 2, annotation?}; shelve with {state: 4, reactivation: {type: "timespan", timespan: {value, magnitude: "minute"|"hour"|"day"…}}}; clear with {state: 5} (a cleared alarm cannot be reactivated). Prefer clearing to deleting: delete removes the history. List filters: state and severity (arrays allowed), name, sort, order, and exact values of nested fields with dots: {"alarm.rule": "<rule id>"}, {"origin.id": "<device>"}, or both (servers before this filter ignore it and list every instance: check the records). `search` matches the instance id and the rule\'s name, not the rule id. A record: {instance, name, description, state, severity, alarm: {rule: <rule id>, group, type}, origin: {source: "device", id: <device>, name}, asset_type, asset_group, activation: {initiated, completed, detections}, normalization: {…}, evaluation: {last_values: {<source>: {<field>: value}}, last_completed}, notification: {last_type, total_completed, total_failed}, project}; times in unix ms.',
|
|
343
353
|
},
|
|
344
354
|
claim: {
|
package/lib/mcp/registry.js
CHANGED
|
@@ -15,6 +15,10 @@ import { expandToolRefs, toolName } from './naming.js';
|
|
|
15
15
|
* @typedef {object} ToolOptions
|
|
16
16
|
* @property {Record<string, import('./resource-hooks.js').ResourceHooks>} [hooks]
|
|
17
17
|
* The CLI's own hooks on catalog types (see resource-hooks.js).
|
|
18
|
+
* @property {Record<string, import('./tools-resource.js').LocalType>} [types]
|
|
19
|
+
* Resource types the CLI adds, served by the same `resource_*` tools.
|
|
20
|
+
* @property {Record<string, Record<string, import('./tools-resource.js').LocalAction>>} [actions]
|
|
21
|
+
* Actions the CLI adds to resource types (the server's or its own).
|
|
18
22
|
*/
|
|
19
23
|
|
|
20
24
|
/**
|
package/lib/mcp/server.js
CHANGED
|
@@ -25,13 +25,16 @@ import { defineCliDocs } from '../docs.js';
|
|
|
25
25
|
* tools?: Array<object>,
|
|
26
26
|
* instructions?: string,
|
|
27
27
|
* resourceHooks?: Record<string, import('./resource-hooks.js').ResourceHooks>,
|
|
28
|
+
* resourceTypes?: Record<string, import('./tools-resource.js').LocalType>,
|
|
29
|
+
* resourceActions?: Record<string, Record<string, import('./tools-resource.js').LocalAction>>,
|
|
28
30
|
* docs?: Parameters<typeof import('../docs.js').defineCliDocs>[0],
|
|
29
31
|
* }} [opts]
|
|
30
32
|
* `version` is echoed in the `initialize` response's `serverInfo.version`;
|
|
31
33
|
* callers pass their `package.json#version` so the two never drift.
|
|
32
34
|
* `tools` are CLI-specific tools added to the generic set.
|
|
33
35
|
* `instructions` is the server-level guidance sent to the client.
|
|
34
|
-
* `resourceHooks` are the CLI's hooks on catalog types
|
|
36
|
+
* `resourceHooks` are the CLI's hooks on catalog types; `resourceTypes`
|
|
37
|
+
* and `resourceActions` the types and actions it adds to the catalog.
|
|
35
38
|
* `docs` are the guides the CLI ships (see docs.js).
|
|
36
39
|
*/
|
|
37
40
|
export async function startMCPServer({
|
|
@@ -39,11 +42,17 @@ export async function startMCPServer({
|
|
|
39
42
|
tools: extra = [],
|
|
40
43
|
instructions,
|
|
41
44
|
resourceHooks,
|
|
45
|
+
resourceTypes,
|
|
46
|
+
resourceActions,
|
|
42
47
|
docs,
|
|
43
48
|
} = {}) {
|
|
44
49
|
const { mcp } = identity();
|
|
45
50
|
defineCliDocs(docs);
|
|
46
|
-
const tools = buildTools(extra, {
|
|
51
|
+
const tools = buildTools(extra, {
|
|
52
|
+
hooks: resourceHooks,
|
|
53
|
+
types: resourceTypes,
|
|
54
|
+
actions: resourceActions,
|
|
55
|
+
});
|
|
47
56
|
|
|
48
57
|
const server = new Server(
|
|
49
58
|
{
|
|
@@ -28,6 +28,131 @@ import { mergeHooks } from './resource-hooks.js';
|
|
|
28
28
|
* document when the model needs it.
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Types and actions a CLI adds to the catalog (`runCli({ resourceTypes,
|
|
33
|
+
* resourceActions })`): its platform's own things, served by the same
|
|
34
|
+
* tools. A CLI type has no REST path: its functions answer each verb.
|
|
35
|
+
*
|
|
36
|
+
* What reaches those functions comes from the model: `parent` and `id` are
|
|
37
|
+
* checked for their number of segments (and `..`, empty or control
|
|
38
|
+
* characters), but `id`, `filter`, `query` and the body are still untrusted
|
|
39
|
+
* input, to be validated before use.
|
|
40
|
+
*
|
|
41
|
+
* @typedef {object} LocalContext
|
|
42
|
+
* @property {string} type
|
|
43
|
+
* @property {string} [parent]
|
|
44
|
+
* @property {string} [id]
|
|
45
|
+
* @property {string | null} [user]
|
|
46
|
+
* @property {Record<string, any>} [filter] resource_list's filter.
|
|
47
|
+
* @property {Record<string, any>} [query] resource_call's query.
|
|
48
|
+
* @property {boolean} [full]
|
|
49
|
+
* @property {Record<string, any>} state
|
|
50
|
+
*
|
|
51
|
+
* @typedef {object} LocalAction
|
|
52
|
+
* @property {string} description
|
|
53
|
+
* @property {'item' | 'collection' | 'either'} [on] Whether it takes an id: item (default)
|
|
54
|
+
* needs one, collection takes none, either takes it or not.
|
|
55
|
+
* @property {object} [body] JSON schema of its body, shown by resource_describe.
|
|
56
|
+
* @property {(body: any, ctx: LocalContext) => any} run Returns the answer: text,
|
|
57
|
+
* anything JSON, or a whole tool result ({content: [{type, text}], isError}) to flag a
|
|
58
|
+
* failure (only actions can; the verbs answer text or JSON).
|
|
59
|
+
*
|
|
60
|
+
* @typedef {object} LocalType
|
|
61
|
+
* @property {string} description One line, shown in the catalog summary.
|
|
62
|
+
* @property {string} [parent] Type this one hangs off (the server's or another CLI
|
|
63
|
+
* type with an id): its location is the `parent`.
|
|
64
|
+
* @property {string} [id] Name of the item id; without one, a single resource of its parent.
|
|
65
|
+
* @property {string} [notes] Longer guidance returned by resource_describe.
|
|
66
|
+
* @property {object} [schema] JSON schema of a record, the body of create and update.
|
|
67
|
+
* @property {() => Record<string, any> | Promise<Record<string, any>>} [describe] More
|
|
68
|
+
* for resource_describe to show (a reference the type's records follow, say).
|
|
69
|
+
* @property {(ctx: LocalContext) => any[] | Promise<any[]>} [list] The whole list:
|
|
70
|
+
* search and paging are done by the tool.
|
|
71
|
+
* @property {(ctx: LocalContext) => any} [get]
|
|
72
|
+
* @property {(body: any, ctx: LocalContext) => any} [create] Returns a result or a note.
|
|
73
|
+
* @property {(body: any, ctx: LocalContext) => any} [update]
|
|
74
|
+
* @property {(ctx: LocalContext) => any} [delete]
|
|
75
|
+
* @property {Record<string, LocalAction>} [actions]
|
|
76
|
+
*/
|
|
77
|
+
|
|
78
|
+
const LOCAL_VERBS = /** @type {const} */ (['list', 'get', 'create', 'update', 'delete']);
|
|
79
|
+
const ACTION_ON = new Set(['item', 'collection', 'either']);
|
|
80
|
+
// eslint-disable-next-line no-control-regex -- control characters are what it looks for
|
|
81
|
+
const CONTROL = /[\x00-\x1f]/;
|
|
82
|
+
|
|
83
|
+
/** An own key of a plain map: never `constructor` or another inherited one. */
|
|
84
|
+
const own = (map, key) =>
|
|
85
|
+
map && typeof key === 'string' && Object.hasOwn(map, key) ? map[key] : undefined;
|
|
86
|
+
|
|
87
|
+
/** A whole MCP tool result, as an action may answer to flag a failure. */
|
|
88
|
+
const isToolResult = (v) =>
|
|
89
|
+
!!v &&
|
|
90
|
+
typeof v === 'object' &&
|
|
91
|
+
Array.isArray(v.content) &&
|
|
92
|
+
v.content.every((c) => c && typeof c === 'object' && typeof c.type === 'string') &&
|
|
93
|
+
Object.keys(v).every((k) => k === 'content' || k === 'isError' || k === '_meta');
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Longest answer a CLI type or action gives (characters of the text sent):
|
|
97
|
+
* a file it reads can be huge. Server types are never cut: a record read to
|
|
98
|
+
* be edited and written back must be whole.
|
|
99
|
+
*/
|
|
100
|
+
export const MAX_TEXT = 100_000;
|
|
101
|
+
|
|
102
|
+
/** `text` cut at MAX_TEXT, saying so. */
|
|
103
|
+
function capped(t) {
|
|
104
|
+
t = String(t ?? '');
|
|
105
|
+
if (t.length <= MAX_TEXT) return t;
|
|
106
|
+
return `${t.slice(0, MAX_TEXT)}\n… (cut: ${t.length} characters in total, showing the first ${MAX_TEXT})`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Check what a CLI adds against the catalog: a type or an action that
|
|
111
|
+
* clashes with the server's is a mistake, found when the server starts.
|
|
112
|
+
*
|
|
113
|
+
* @param {Record<string, LocalType>} types
|
|
114
|
+
* @param {Record<string, Record<string, LocalAction>>} actions
|
|
115
|
+
*/
|
|
116
|
+
export function checkLocal(types, actions) {
|
|
117
|
+
for (const [name, t] of Object.entries(types)) {
|
|
118
|
+
if (CATALOG[name]) throw new Error(`Resource type "${name}" is already a server type`);
|
|
119
|
+
if (!t.description) throw new Error(`Resource type "${name}" needs a description`);
|
|
120
|
+
if (t.parent && !CATALOG[t.parent] && !own(types, t.parent)) {
|
|
121
|
+
throw new Error(`Resource type "${name}": unknown parent "${t.parent}"`);
|
|
122
|
+
}
|
|
123
|
+
const seen = new Set([name]);
|
|
124
|
+
for (let p = t.parent; p && own(types, p); p = types[p].parent) {
|
|
125
|
+
if (seen.has(p)) throw new Error(`Resource type "${name}": its parents loop`);
|
|
126
|
+
if (!types[p].id) throw new Error(`Resource type "${name}": parent "${p}" has no id`);
|
|
127
|
+
seen.add(p);
|
|
128
|
+
}
|
|
129
|
+
if (!LOCAL_VERBS.some((v) => typeof t[v] === 'function') && !t.actions) {
|
|
130
|
+
throw new Error(`Resource type "${name}" has no operations`);
|
|
131
|
+
}
|
|
132
|
+
for (const [a, spec] of Object.entries(t.actions || {})) checkAction(name, a, spec);
|
|
133
|
+
}
|
|
134
|
+
for (const [type, list] of Object.entries(actions)) {
|
|
135
|
+
if (!CATALOG[type] && !types[type]) {
|
|
136
|
+
throw new Error(`Actions for unknown resource type "${type}"`);
|
|
137
|
+
}
|
|
138
|
+
for (const [a, spec] of Object.entries(list)) {
|
|
139
|
+
if (CATALOG[type]?.actions?.[a] || types[type]?.actions?.[a]) {
|
|
140
|
+
throw new Error(`Resource type "${type}" already has an action "${a}"`);
|
|
141
|
+
}
|
|
142
|
+
checkAction(type, a, spec);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function checkAction(type, name, spec) {
|
|
148
|
+
if (typeof spec?.run !== 'function' || !spec.description) {
|
|
149
|
+
throw new Error(`Action "${name}" of "${type}" needs a description and run()`);
|
|
150
|
+
}
|
|
151
|
+
if (spec.on !== undefined && !ACTION_ON.has(spec.on)) {
|
|
152
|
+
throw new Error(`Action "${name}" of "${type}": on must be item, collection or either`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
31
156
|
/** `{tool:x}` in catalog notes → full tool names (tests check they exist). */
|
|
32
157
|
const expandNotes = (notes) => notes.replace(/\{tool:([a-z0-9_]+)\}/g, (_, base) => toolName(base));
|
|
33
158
|
|
|
@@ -209,7 +334,7 @@ function responseExample(operation) {
|
|
|
209
334
|
}
|
|
210
335
|
|
|
211
336
|
/** Which tool runs each verb of a type. */
|
|
212
|
-
function operationsOf(
|
|
337
|
+
function operationsOf(ops) {
|
|
213
338
|
const tools = {
|
|
214
339
|
list: toolName('resource_list'),
|
|
215
340
|
get: toolName('resource_get'),
|
|
@@ -220,30 +345,115 @@ function operationsOf(type) {
|
|
|
220
345
|
clone: toolName('resource_clone'),
|
|
221
346
|
projects: `${toolName('resource_update')} with {projects: [...]}`,
|
|
222
347
|
};
|
|
223
|
-
return Object.fromEntries(Object.keys(
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
function catalogSummary(names) {
|
|
227
|
-
const lines = names.map((name) => {
|
|
228
|
-
const t = CATALOG[name];
|
|
229
|
-
const parent = t.parent ? ` (parent ${parentHint(name)})` : '';
|
|
230
|
-
const id = idNames(t).length ? '' : ' (single, no id)';
|
|
231
|
-
const actions = Object.keys(t.actions || {});
|
|
232
|
-
const ops =
|
|
233
|
-
Object.keys(t.ops).join(' ') + (actions.length ? ` | ${actions.join(' ')}` : '');
|
|
234
|
-
return `${name}${parent}${id}: ${expandNotes(t.description)} [${ops}]`;
|
|
235
|
-
});
|
|
236
|
-
return `Resource types, with their operations and, after |, their actions (run with ${toolName('resource_call')}). ${toolName('resource_describe')} with a type gives its schemas and examples.\n\n${lines.join('\n')}`;
|
|
348
|
+
return Object.fromEntries(Object.keys(ops).map((verb) => [verb, tools[verb]]));
|
|
237
349
|
}
|
|
238
350
|
|
|
239
351
|
/**
|
|
240
|
-
* The tools,
|
|
352
|
+
* The tools, over the catalog and what the CLI adds to it.
|
|
241
353
|
*
|
|
242
|
-
* @param {{
|
|
354
|
+
* @param {{
|
|
355
|
+
* hooks?: Record<string, import('./resource-hooks.js').ResourceHooks>,
|
|
356
|
+
* types?: Record<string, LocalType>,
|
|
357
|
+
* actions?: Record<string, Record<string, LocalAction>>,
|
|
358
|
+
* }} [opts]
|
|
243
359
|
*/
|
|
244
|
-
export function resourceTools({ hooks: extraHooks } = {}) {
|
|
360
|
+
export function resourceTools({ hooks: extraHooks, types: local = {}, actions: extra = {} } = {}) {
|
|
361
|
+
checkLocal(local, extra);
|
|
245
362
|
const hooks = mergeHooks(extraHooks);
|
|
246
|
-
const served = new Set(typeNames());
|
|
363
|
+
const served = new Set([...typeNames(), ...Object.keys(local)]);
|
|
364
|
+
|
|
365
|
+
/** Verbs of a type: the catalog's, or the functions a CLI type has. */
|
|
366
|
+
const opsOf = (name) =>
|
|
367
|
+
own(local, name)
|
|
368
|
+
? Object.fromEntries(
|
|
369
|
+
LOCAL_VERBS.filter((v) => typeof local[name][v] === 'function').map((v) => [
|
|
370
|
+
v,
|
|
371
|
+
'cli',
|
|
372
|
+
]),
|
|
373
|
+
)
|
|
374
|
+
: CATALOG[name].ops;
|
|
375
|
+
/** Actions a CLI adds, to its own types or to the server's. */
|
|
376
|
+
const localActionsOf = (name) => ({
|
|
377
|
+
...(own(local, name)?.actions || {}),
|
|
378
|
+
...(own(extra, name) || {}),
|
|
379
|
+
});
|
|
380
|
+
const actionNames = (name) => [
|
|
381
|
+
...Object.keys(CATALOG[name]?.actions || {}),
|
|
382
|
+
...Object.keys(localActionsOf(name)),
|
|
383
|
+
];
|
|
384
|
+
const namesWith = (verb) => [...served].filter((n) => verb in opsOf(n));
|
|
385
|
+
/** How the parent of a type is written: `<device>`, `<device>/<bucket>`. */
|
|
386
|
+
const parentOf = (name) => {
|
|
387
|
+
if (!own(local, name)) return parentHint(name);
|
|
388
|
+
const p = local[name].parent;
|
|
389
|
+
return p ? [parentOf(p), idOf(p)].filter(Boolean).join('/') : '';
|
|
390
|
+
};
|
|
391
|
+
function idOf(name) {
|
|
392
|
+
if (!own(local, name)) return idHint(name);
|
|
393
|
+
return local[name].id ? `<${local[name].id}>` : '';
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* `parent` (and `id`, when needed) as many segments as the type's
|
|
398
|
+
* location has, none empty, `.`, `..` or with control characters. The
|
|
399
|
+
* id of a CLI type is one value, which may hold "/" (a path).
|
|
400
|
+
*/
|
|
401
|
+
function checkLocation(args, { needsId }) {
|
|
402
|
+
const bad = (part) => !part || part === '.' || part === '..' || CONTROL.test(part);
|
|
403
|
+
const segments = (value, hint, what) => {
|
|
404
|
+
if (!hint) return;
|
|
405
|
+
const parts = value === undefined || value === null ? [] : String(value).split('/');
|
|
406
|
+
if (parts.length !== hint.split('/').length || parts.some(bad)) {
|
|
407
|
+
throw inputError(`${what} (${hint}) is required`);
|
|
408
|
+
}
|
|
409
|
+
};
|
|
410
|
+
segments(args.parent, parentOf(args.type), 'parent');
|
|
411
|
+
if (!needsId || !idOf(args.type)) return;
|
|
412
|
+
if (own(local, args.type)) {
|
|
413
|
+
const id = args.id === undefined || args.id === null ? '' : String(args.id);
|
|
414
|
+
if (!id || CONTROL.test(id))
|
|
415
|
+
throw inputError(`id (${idOf(args.type)}) is required`);
|
|
416
|
+
} else {
|
|
417
|
+
segments(args.id, idOf(args.type), 'id');
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function catalogSummary(names) {
|
|
422
|
+
const lines = names.map((name) => {
|
|
423
|
+
const t = local[name] || CATALOG[name];
|
|
424
|
+
const parent = t.parent ? ` (parent ${parentOf(name)})` : '';
|
|
425
|
+
const id = idOf(name) ? '' : ' (single, no id)';
|
|
426
|
+
const actions = actionNames(name);
|
|
427
|
+
const ops =
|
|
428
|
+
Object.keys(opsOf(name)).join(' ') +
|
|
429
|
+
(actions.length ? ` | ${actions.join(' ')}` : '');
|
|
430
|
+
return `${name}${parent}${id}: ${expandNotes(t.description)} [${ops}]`;
|
|
431
|
+
});
|
|
432
|
+
return `Resource types, with their operations and, after |, their actions (run with ${toolName('resource_call')}). ${toolName('resource_describe')} with a type gives its schemas and examples.\n\n${lines.join('\n')}`;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** Context of a call on a CLI type or action, with its parent and id checked. */
|
|
436
|
+
function localContext(args, { needsId = true, state = {} } = {}) {
|
|
437
|
+
checkLocation(args, { needsId });
|
|
438
|
+
return {
|
|
439
|
+
type: args.type,
|
|
440
|
+
parent: args.parent,
|
|
441
|
+
id: args.id,
|
|
442
|
+
user: args.user,
|
|
443
|
+
filter: args.filter,
|
|
444
|
+
query: args.query,
|
|
445
|
+
full: args.full === true,
|
|
446
|
+
state,
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** What a CLI function answered, as tool text. */
|
|
451
|
+
const answer = (value) =>
|
|
452
|
+
value === undefined || value === null || value === ''
|
|
453
|
+
? undefined
|
|
454
|
+
: typeof value === 'string'
|
|
455
|
+
? value
|
|
456
|
+
: JSON.stringify(value, null, 2);
|
|
247
457
|
|
|
248
458
|
/**
|
|
249
459
|
* Validate the type for a verb and build the hook context.
|
|
@@ -258,10 +468,16 @@ export function resourceTools({ hooks: extraHooks } = {}) {
|
|
|
258
468
|
`Unknown type "${type ?? ''}". ${toolName('resource_describe')} lists them.`,
|
|
259
469
|
);
|
|
260
470
|
}
|
|
261
|
-
if (!(verb in
|
|
262
|
-
const allowed = Object.keys(
|
|
471
|
+
if (!(verb in opsOf(type))) {
|
|
472
|
+
const allowed = Object.keys(opsOf(type)).join(', ');
|
|
263
473
|
throw inputError(`Type "${type}" does not support ${verb} (it supports: ${allowed})`);
|
|
264
474
|
}
|
|
475
|
+
// CLI types: located before any hook runs; the id of a create may
|
|
476
|
+
// still come from the body.
|
|
477
|
+
if (own(local, type)) {
|
|
478
|
+
if (args.merge) throw inputError(`${type} takes no merge: send the fields to change`);
|
|
479
|
+
checkLocation(args, { needsId: verb !== 'list' && verb !== 'create' });
|
|
480
|
+
}
|
|
265
481
|
const ctx = {
|
|
266
482
|
type,
|
|
267
483
|
parent: args.parent,
|
|
@@ -284,6 +500,52 @@ export function resourceTools({ hooks: extraHooks } = {}) {
|
|
|
284
500
|
return body;
|
|
285
501
|
}
|
|
286
502
|
|
|
503
|
+
/** Actions as resource_describe shows them: how to run, what they do, their body. */
|
|
504
|
+
function describeActions(actions) {
|
|
505
|
+
return Object.fromEntries(
|
|
506
|
+
Object.entries(actions).map(([a, spec]) => {
|
|
507
|
+
const entry = {
|
|
508
|
+
run: `${toolName('resource_call')} action "${a}"`,
|
|
509
|
+
does: expandNotes(spec.description),
|
|
510
|
+
};
|
|
511
|
+
if (typeof spec.run === 'function') {
|
|
512
|
+
const on = spec.on || 'item';
|
|
513
|
+
if (on !== 'item') entry.id = on === 'collection' ? 'none' : 'optional';
|
|
514
|
+
if (spec.body) entry.body = { schema: spec.body };
|
|
515
|
+
} else if (spec.on === 'collection') {
|
|
516
|
+
entry.id = 'none';
|
|
517
|
+
}
|
|
518
|
+
return [a, entry];
|
|
519
|
+
}),
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** resource_describe of a CLI type: everything it says comes from its definition. */
|
|
524
|
+
async function describeLocal(name) {
|
|
525
|
+
const t = local[name];
|
|
526
|
+
const out = { type: name, description: expandNotes(t.description) };
|
|
527
|
+
if (t.parent) out.parent = `${t.parent} (write it as "${parentOf(name)}")`;
|
|
528
|
+
out.id = t.id ? idOf(name) : 'none: a single resource of its parent';
|
|
529
|
+
out.operations = operationsOf(opsOf(name));
|
|
530
|
+
if (t.schema) out.fields = t.schema;
|
|
531
|
+
const actions = localActionsOf(name);
|
|
532
|
+
if (Object.keys(actions).length) out.actions = describeActions(actions);
|
|
533
|
+
if (t.notes) out.notes = expandNotes(t.notes);
|
|
534
|
+
if (t.describe) {
|
|
535
|
+
try {
|
|
536
|
+
Object.assign(out, await t.describe());
|
|
537
|
+
} catch (e) {
|
|
538
|
+
out.reference = `unavailable: ${e instanceof Error ? e.message : String(e)}`;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
return Object.entries(out)
|
|
542
|
+
.map(
|
|
543
|
+
([key, value]) =>
|
|
544
|
+
`${key}: ${typeof value === 'string' ? value : JSON.stringify(value)}`,
|
|
545
|
+
)
|
|
546
|
+
.join('\n');
|
|
547
|
+
}
|
|
548
|
+
|
|
287
549
|
async function toolDescribe(args) {
|
|
288
550
|
if (!args.type) {
|
|
289
551
|
// The overview call: the types, plus the server's guide to its
|
|
@@ -302,23 +564,15 @@ export function resourceTools({ hooks: extraHooks } = {}) {
|
|
|
302
564
|
throw inputError(`Unknown type "${args.type}".\n\n${catalogSummary([...served])}`);
|
|
303
565
|
}
|
|
304
566
|
const name = args.type;
|
|
567
|
+
if (own(local, name)) return text(capped(await describeLocal(name)));
|
|
305
568
|
const type = CATALOG[name];
|
|
306
569
|
const out = { type: name, description: expandNotes(type.description) };
|
|
307
570
|
const chain = ancestry(name);
|
|
308
571
|
if (chain.length) out.parent = `${chain.join(' > ')} (write it as "${parentHint(name)}")`;
|
|
309
572
|
out.id = idNames(type).length ? idHint(name) : 'none: a single resource of its parent';
|
|
310
|
-
out.operations = operationsOf(type);
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
Object.entries(type.actions).map(([a, spec]) => [
|
|
314
|
-
a,
|
|
315
|
-
{
|
|
316
|
-
run: `${toolName('resource_call')} action "${a}"`,
|
|
317
|
-
does: expandNotes(spec.description),
|
|
318
|
-
},
|
|
319
|
-
]),
|
|
320
|
-
);
|
|
321
|
-
}
|
|
573
|
+
out.operations = operationsOf(type.ops);
|
|
574
|
+
const allActions = { ...(type.actions || {}), ...localActionsOf(name) };
|
|
575
|
+
if (Object.keys(allActions).length) out.actions = describeActions(allActions);
|
|
322
576
|
if (type.notes) out.notes = expandNotes(type.notes);
|
|
323
577
|
if (type.sdk) {
|
|
324
578
|
out.sdk = await readDoc('dashboard-sdk').then(
|
|
@@ -381,7 +635,12 @@ export function resourceTools({ hooks: extraHooks } = {}) {
|
|
|
381
635
|
}
|
|
382
636
|
|
|
383
637
|
async function toolList(args) {
|
|
384
|
-
const { type } = prepare(args, 'list');
|
|
638
|
+
const { type, ctx } = prepare(args, 'list');
|
|
639
|
+
if (own(local, type)) {
|
|
640
|
+
const lctx = localContext(args, { needsId: false, state: ctx.state });
|
|
641
|
+
const data = (await local[type].list?.(lctx)) ?? [];
|
|
642
|
+
return text(capped(pageOf(type, data, args, true)));
|
|
643
|
+
}
|
|
385
644
|
const params = { ...(CATALOG[type].listQuery || {}), ...(args.filter || {}) };
|
|
386
645
|
// Search: the server matches `name` against ids and names (partial,
|
|
387
646
|
// case-insensitive, as a regular expression: escape it). Lists the
|
|
@@ -395,6 +654,14 @@ export function resourceTools({ hooks: extraHooks } = {}) {
|
|
|
395
654
|
let data = await sendList(type, args, params);
|
|
396
655
|
const fields = CATALOG[type].listFields;
|
|
397
656
|
if (fields && Array.isArray(data)) data = data.map((record) => pick(record, fields));
|
|
657
|
+
return text(pageOf(type, data, args, whole));
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* A list as resource_list answers it: searched here when the source
|
|
662
|
+
* answers it whole, and paged here when it ignores count/index.
|
|
663
|
+
*/
|
|
664
|
+
function pageOf(type, data, args, whole) {
|
|
398
665
|
// Lists answered whole: every word must appear in what a record shows.
|
|
399
666
|
if (args.search && whole && Array.isArray(data)) {
|
|
400
667
|
const words = String(args.search).toLowerCase().split(/\s+/).filter(Boolean);
|
|
@@ -406,29 +673,32 @@ export function resourceTools({ hooks: extraHooks } = {}) {
|
|
|
406
673
|
// Some collections ignore count/index and answer everything: page
|
|
407
674
|
// them here so one call cannot flood the conversation.
|
|
408
675
|
const pageSize = args.count ?? 50;
|
|
409
|
-
|
|
410
|
-
|
|
676
|
+
const from = args.index ?? 0;
|
|
677
|
+
if (Array.isArray(data) && (data.length > pageSize || (whole && from > 0))) {
|
|
411
678
|
const page = data.slice(from, from + pageSize);
|
|
412
679
|
const next =
|
|
413
680
|
from + page.length < data.length
|
|
414
681
|
? `; pass index: ${from + page.length} for more, or narrow it with filter`
|
|
415
682
|
: '';
|
|
416
|
-
return
|
|
417
|
-
`${formatList(type, page, Infinity)}\n(${data.length} in total, showing ${from}–${from + page.length - 1}${next})`,
|
|
418
|
-
);
|
|
683
|
+
return `${formatList(type, page, Infinity)}\n(${data.length} in total, showing ${from}–${from + page.length - 1}${next})`;
|
|
419
684
|
}
|
|
420
|
-
return
|
|
685
|
+
return formatList(type, data, whole ? Infinity : pageSize);
|
|
421
686
|
}
|
|
422
687
|
|
|
423
688
|
async function toolGet(args) {
|
|
424
689
|
const { type, ctx, hook } = prepare(args, 'get');
|
|
425
|
-
|
|
690
|
+
const mine = own(local, type);
|
|
691
|
+
let record = mine
|
|
692
|
+
? await mine.get?.(localContext(args, { state: ctx.state }))
|
|
693
|
+
: await send(type, 'get', args);
|
|
426
694
|
if (hook.afterGet) record = await hook.afterGet(record, ctx);
|
|
427
|
-
return text(JSON.stringify(record, null, 2));
|
|
695
|
+
if (!mine) return text(JSON.stringify(record, null, 2));
|
|
696
|
+
return text(capped(answer(record) ?? `No ${label(type, args)}.`));
|
|
428
697
|
}
|
|
429
698
|
|
|
430
699
|
async function toolCreate(args) {
|
|
431
700
|
const { type, ctx, hook } = prepare(args, 'create');
|
|
701
|
+
if (own(local, type)) return createLocal(args, ctx, hook);
|
|
432
702
|
const t = CATALOG[type];
|
|
433
703
|
let body = bodyArg(args, 'create');
|
|
434
704
|
// `projects` is not a field the server takes on create: set it right
|
|
@@ -472,9 +742,46 @@ export function resourceTools({ hooks: extraHooks } = {}) {
|
|
|
472
742
|
return text(lines.join('\n'));
|
|
473
743
|
}
|
|
474
744
|
|
|
745
|
+
async function createLocal(args, ctx, hook) {
|
|
746
|
+
const t = local[args.type];
|
|
747
|
+
let body = bodyArg(args, 'create');
|
|
748
|
+
if (Array.isArray(body)) throw inputError('body must be a JSON object');
|
|
749
|
+
if (t.id && args.id !== undefined && body[t.id] === undefined)
|
|
750
|
+
body = { [t.id]: args.id, ...body };
|
|
751
|
+
// The hook may set or normalise the id: read it after.
|
|
752
|
+
if (hook.beforeCreate) body = await hook.beforeCreate(body, ctx);
|
|
753
|
+
const id = args.id ?? (t.id ? body?.[t.id] : undefined);
|
|
754
|
+
if (t.id && (id === undefined || id === null || id === '')) {
|
|
755
|
+
throw inputError(`give the ${args.type} id: \`id\`, or the body field "${t.id}"`);
|
|
756
|
+
}
|
|
757
|
+
ctx.id = id;
|
|
758
|
+
const lctx = localContext({ ...args, id }, { state: ctx.state });
|
|
759
|
+
const result = await t.create?.(body, lctx);
|
|
760
|
+
const lines = [`Created ${label(args.type, { parent: args.parent, id })}.`];
|
|
761
|
+
const said = answer(result);
|
|
762
|
+
if (said) lines.push(said);
|
|
763
|
+
const note = hook.afterCreate && (await hook.afterCreate(result, ctx));
|
|
764
|
+
if (note) lines.push('', note);
|
|
765
|
+
return text(lines.join('\n'));
|
|
766
|
+
}
|
|
767
|
+
|
|
475
768
|
async function toolUpdate(args) {
|
|
476
769
|
/** @type {import('./catalog.js').Verb} */
|
|
477
770
|
const verb = args.merge ? 'patch' : 'update';
|
|
771
|
+
if (own(local, args.type)) {
|
|
772
|
+
const { type, ctx, hook } = prepare(args, 'update');
|
|
773
|
+
let body = bodyArg(args, 'update');
|
|
774
|
+
if (Array.isArray(body)) throw inputError('body must be a JSON object');
|
|
775
|
+
if (hook.beforeUpdate) body = await hook.beforeUpdate(body, ctx);
|
|
776
|
+
const result = await local[type].update?.(
|
|
777
|
+
body,
|
|
778
|
+
localContext(args, { state: ctx.state }),
|
|
779
|
+
);
|
|
780
|
+
const said = answer(result);
|
|
781
|
+
return text(
|
|
782
|
+
`Updated ${label(type, args)}: ${Object.keys(body).join(', ')}.${said ? `\n${said}` : ''}`,
|
|
783
|
+
);
|
|
784
|
+
}
|
|
478
785
|
const { projects, ...fields } = bodyArg(args, 'update');
|
|
479
786
|
const { type, ctx, hook } = prepare(args, projects !== undefined ? 'projects' : verb);
|
|
480
787
|
const done = [];
|
|
@@ -506,8 +813,13 @@ export function resourceTools({ hooks: extraHooks } = {}) {
|
|
|
506
813
|
|
|
507
814
|
async function toolDelete(args) {
|
|
508
815
|
const { type, ctx, hook } = prepare(args, 'delete');
|
|
509
|
-
|
|
816
|
+
const mine = own(local, type);
|
|
817
|
+
const result = mine
|
|
818
|
+
? await mine.delete?.(localContext(args, { state: ctx.state }))
|
|
819
|
+
: await send(type, 'delete', args);
|
|
510
820
|
const lines = [`Deleted ${label(type, args)}.`];
|
|
821
|
+
const said = mine && answer(result);
|
|
822
|
+
if (said) lines.push(said);
|
|
511
823
|
const note = hook.afterDelete && (await hook.afterDelete(ctx));
|
|
512
824
|
if (note) lines.push(note);
|
|
513
825
|
return text(lines.join('\n'));
|
|
@@ -543,11 +855,36 @@ export function resourceTools({ hooks: extraHooks } = {}) {
|
|
|
543
855
|
|
|
544
856
|
async function toolCall(args) {
|
|
545
857
|
const type = args.type;
|
|
546
|
-
|
|
858
|
+
const known = typeof type === 'string' && served.has(type);
|
|
859
|
+
const mine = known ? own(localActionsOf(type), args.action) : undefined;
|
|
860
|
+
if (mine) {
|
|
861
|
+
const on = mine.on || 'item';
|
|
862
|
+
if (on === 'collection' && args.id !== undefined) {
|
|
863
|
+
throw inputError(`${args.action} takes no id`);
|
|
864
|
+
}
|
|
865
|
+
const ctx = localContext(args, { needsId: on === 'item' });
|
|
866
|
+
const result = await mine.run(args.body, ctx);
|
|
867
|
+
if (isToolResult(result)) {
|
|
868
|
+
return {
|
|
869
|
+
...result,
|
|
870
|
+
content: result.content.map((c) =>
|
|
871
|
+
c.type === 'text' ? { ...c, text: capped(c.text) } : c,
|
|
872
|
+
),
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
const said = answer(result);
|
|
876
|
+
return text(capped(said ?? `Ran ${args.action} on ${label(type, args)}.`));
|
|
877
|
+
}
|
|
878
|
+
if (!known || !actionNames(type).length) {
|
|
547
879
|
throw inputError(
|
|
548
880
|
`Type "${type ?? ''}" has no actions. ${toolName('resource_describe')} lists them after |.`,
|
|
549
881
|
);
|
|
550
882
|
}
|
|
883
|
+
if (!own(CATALOG[type]?.actions, args.action)) {
|
|
884
|
+
throw inputError(
|
|
885
|
+
`Type "${type}" has no action "${args.action}" (it has: ${actionNames(type).join(', ')})`,
|
|
886
|
+
);
|
|
887
|
+
}
|
|
551
888
|
const { method, url, query } = actionRequest(type, args.action, args);
|
|
552
889
|
const spec = CATALOG[type].actions?.[args.action];
|
|
553
890
|
const started = Date.now();
|
|
@@ -585,7 +922,7 @@ export function resourceTools({ hooks: extraHooks } = {}) {
|
|
|
585
922
|
|
|
586
923
|
const typeParam = (verb) => ({
|
|
587
924
|
type: 'string',
|
|
588
|
-
enum:
|
|
925
|
+
enum: verb ? namesWith(verb) : [...served],
|
|
589
926
|
description: 'Resource type (see {tool:resource_describe}).',
|
|
590
927
|
});
|
|
591
928
|
const common = {
|
|
@@ -609,7 +946,7 @@ export function resourceTools({ hooks: extraHooks } = {}) {
|
|
|
609
946
|
"Describe a resource type: its parent and id, the operations it supports and, from this server's API, the request schemas and examples for create, update and clone. Call it before writing a type for the first time. Without `type`: all types and a guide to the platform's concepts (start here).",
|
|
610
947
|
inputSchema: {
|
|
611
948
|
type: 'object',
|
|
612
|
-
properties: { type:
|
|
949
|
+
properties: { type: typeParam() },
|
|
613
950
|
required: [],
|
|
614
951
|
},
|
|
615
952
|
handler: toolDescribe,
|
|
@@ -650,7 +987,7 @@ export function resourceTools({ hooks: extraHooks } = {}) {
|
|
|
650
987
|
full: {
|
|
651
988
|
type: 'boolean',
|
|
652
989
|
description:
|
|
653
|
-
'Plugin catalog manifests
|
|
990
|
+
'Plugin catalog manifests: also include the product code and the console dashboard (large). Types that take it say so in their notes.',
|
|
654
991
|
},
|
|
655
992
|
user: common.user,
|
|
656
993
|
},
|
|
@@ -742,7 +1079,7 @@ export function resourceTools({ hooks: extraHooks } = {}) {
|
|
|
742
1079
|
properties: {
|
|
743
1080
|
type: {
|
|
744
1081
|
type: 'string',
|
|
745
|
-
enum:
|
|
1082
|
+
enum: [...served].filter((n) => actionNames(n).length),
|
|
746
1083
|
description: 'Resource type.',
|
|
747
1084
|
},
|
|
748
1085
|
action: { type: 'string', description: 'Action name, e.g. "verify".' },
|
package/package.json
CHANGED