@tenonhq/dovetail-servicenow 0.0.25 → 0.0.26

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/README.md CHANGED
@@ -319,6 +319,39 @@ var r = await setField({
319
319
  console.log(r.status, r.verified); // "applied" true
320
320
  ```
321
321
 
322
+ ### Invoke an arbitrary REST operation
323
+
324
+ Invoke any authenticated ServiceNow REST operation — an application's own
325
+ Scripted REST endpoints (`sys_ws_operation` at `/api/<scope>/<service>/<resource>`)
326
+ included — with GET, POST, PUT or DELETE. This is the transport primitive for
327
+ operations the fixed verbs can't express, and the only surface with PUT/DELETE
328
+ coverage (a verification harness that cleans up after itself needs the DELETE).
329
+
330
+ ```bash
331
+ # Dry-run — the DEFAULT: echoes method + path + body, sends NOTHING
332
+ npx dove-sn invoke-rest --method DELETE \
333
+ --path /api/x_cadso_core/testkit/resource/<sys_id>
334
+
335
+ # Send for real
336
+ npx dove-sn invoke-rest --method PUT \
337
+ --path /api/x_cadso_core/testkit/resource/<sys_id> \
338
+ --body '{"name":"updated"}' --confirm --json
339
+ ```
340
+
341
+ `invoke-rest` is **dry-run by default** — nothing is sent without `--confirm`
342
+ (`--dry-run` forces a dry-run even with it). On send the response passes through
343
+ **verbatim** as `{ httpStatus, ok, body }`: non-2xx responses are returned, not
344
+ thrown, so the operation's own error contract survives (the transport still
345
+ retries 429/5xx first). The path must be instance-relative and start with
346
+ `/api/`. **Bodies are never printed in human output** — request or response,
347
+ dry-run or sent: method, path and status only. The structured `--json` result
348
+ is the one channel that carries them (a dry-run's `requestBody` echo lives
349
+ there). Exit codes: `0` dry-run or 2xx, `1` bad args, `2` sent but non-2xx.
350
+
351
+ Programmatic: `invokeRest({ method, path, body, confirm })` is exported, and the
352
+ client gained `now.put` / `now.delete` / `now.invoke` (the latter returns
353
+ `{ status, body }` verbatim) alongside the existing `now.get` / `now.post`.
354
+
322
355
  `test-flow` defaults to **validate** — a safe pre-flight (published? inputs match
323
356
  declared variables?) that never runs the flow; `--execute --confirm` runs it via
324
357
  the server-side FlowAPI runner (deploy `resources/runFlow.md` first).
@@ -397,8 +430,10 @@ tools `flow_view` (read a flow/subflow's step graph), `action_view` (read an act
397
430
  type's model), `flow_publish` (compile a flow/subflow snapshot), `flow_copy`
398
431
  (copy a flow as an inactive draft), `flow_create` (create a NEW flow from scratch +
399
432
  publish, grafting a template), `flow_test` (validate or run a flow), and
400
- `flow_edit` (patch a flow). It reads ServiceNow credentials from the same env
401
- vars as the CLI.
433
+ `flow_edit` (patch a flow), plus `invoke_rest` (invoke an arbitrary authenticated
434
+ REST operation — Scripted REST included — with GET/POST/PUT/DELETE; dry-run by
435
+ default, response passed through verbatim, bodies never logged). It reads
436
+ ServiceNow credentials from the same env vars as the CLI.
402
437
 
403
438
  ```bash
404
439
  npx dove-sn mcp --smoke # list the registered tools and exit
package/dist/cli.js CHANGED
@@ -81,6 +81,7 @@ const testFlow_1 = require("./flowDesigner/testFlow");
81
81
  const table_1 = require("./table");
82
82
  const setField_1 = require("./setField");
83
83
  const createRecord_1 = require("./createRecord");
84
+ const invokeRest_1 = require("./invokeRest");
84
85
  const hostAssets_1 = require("./hostAssets");
85
86
  const flowDesigner_formatter_2 = require("./flowDesigner-formatter");
86
87
  function parseArgs(argv) {
@@ -719,6 +720,10 @@ function printHelp() {
719
720
  " create-record Create ONE NEW record in a data table, into an update set, then verify\n" +
720
721
  " (--table <t> --fields \"k=v,k2=v2\" --scope <s> --update-set <sys_id>\n" +
721
722
  " [--if-absent <encoded-query>] [--dry-run] [--json])\n" +
723
+ " invoke-rest Invoke an arbitrary authenticated REST operation (Scripted REST incl.)\n" +
724
+ " DRY-RUN BY DEFAULT — nothing is sent without --confirm\n" +
725
+ " (--method <GET|POST|PUT|DELETE> --path /api/<scope>/<service>/<resource>\n" +
726
+ " [--body '<json>' | --body-json <path>] [--confirm] [--dry-run] [--json])\n" +
722
727
  " host-assets Deploy a built dist/ to ServiceNow (carrier sys_ui_script + attachment + m2m)\n" +
723
728
  " (--dir <dist> --app <sys_id> --scope <namespace>\n" +
724
729
  " [--update-set <sys_id>] [--max-bytes <n>] [--allow-oversize] [--dry-run] [--json])\n" +
@@ -988,6 +993,80 @@ async function runCreateRecord(flags) {
988
993
  return 2;
989
994
  return 0;
990
995
  }
996
+ /**
997
+ * dove-sn invoke-rest:
998
+ * --method <GET|POST|PUT|DELETE> Required.
999
+ * --path </api/...> Required. Instance-relative; must start with /api/.
1000
+ * --body '<json>' Optional inline JSON body (or --body-json <path>).
1001
+ * --confirm Send for real. WITHOUT it the command is a DRY-RUN.
1002
+ * --dry-run Force a dry-run even with --confirm.
1003
+ * --json Emit the structured InvokeRestResult.
1004
+ *
1005
+ * Invoke an arbitrary authenticated REST operation (Scripted REST included).
1006
+ * Dry-run by default; --confirm sends and returns { httpStatus, ok, body } with
1007
+ * the response passed through verbatim (non-2xx included — the transport still
1008
+ * retries 429/5xx first). Bodies are NEVER printed in human output — request or
1009
+ * response, dry-run or sent: method, path and status only. The structured
1010
+ * --json result is the one channel that carries them (a dry-run's requestBody
1011
+ * echo satisfies the #212 "echo the plan" gate there).
1012
+ * Exit codes: 0 dry-run or 2xx, 1 bad args, 2 sent but non-2xx.
1013
+ */
1014
+ async function runInvokeRest(flags) {
1015
+ if (!flags.method || !flags.path) {
1016
+ process.stderr.write("invoke-rest: --method <GET|POST|PUT|DELETE> and --path </api/...> are required\n");
1017
+ return 1;
1018
+ }
1019
+ var body;
1020
+ if (flags["body-json"]) {
1021
+ try {
1022
+ body = JSON.parse(fs.readFileSync(flags["body-json"], "utf8"));
1023
+ }
1024
+ catch (err) {
1025
+ process.stderr.write("invoke-rest: --body-json must point to a readable JSON file: "
1026
+ + (err && err.message ? err.message : String(err)) + "\n");
1027
+ return 1;
1028
+ }
1029
+ }
1030
+ else if (flags.body !== undefined) {
1031
+ try {
1032
+ body = JSON.parse(flags.body);
1033
+ }
1034
+ catch (err) {
1035
+ process.stderr.write("invoke-rest: --body must be valid JSON: " + err.message + "\n");
1036
+ return 1;
1037
+ }
1038
+ }
1039
+ var params = {
1040
+ method: flags.method,
1041
+ path: flags.path,
1042
+ confirm: flags.confirm === "true",
1043
+ dryRun: flags["dry-run"] === "true"
1044
+ };
1045
+ if (body !== undefined) {
1046
+ params.body = body;
1047
+ }
1048
+ var result = await (0, invokeRest_1.invokeRest)(params);
1049
+ if (flags.json === "true") {
1050
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
1051
+ }
1052
+ else if (result.status === "dry-run") {
1053
+ process.stdout.write("[dry-run] " + result.method + " " + result.path + "\n"
1054
+ + (result.requestBody !== undefined
1055
+ ? "Request body withheld from human output — use --json to view.\n"
1056
+ : "")
1057
+ + result.note + "\n");
1058
+ }
1059
+ else {
1060
+ // Bodies are never logged: human output is method + path + status only.
1061
+ process.stdout.write("[sent] " + result.method + " " + result.path + " -> HTTP " + result.httpStatus
1062
+ + (result.ok ? "" : " (non-2xx)") + "\n"
1063
+ + "Response body withheld from human output — use --json for { httpStatus, ok, body }.\n");
1064
+ }
1065
+ if (result.status === "sent" && result.ok !== true) {
1066
+ return 2;
1067
+ }
1068
+ return 0;
1069
+ }
991
1070
  /**
992
1071
  * dove-sn host-assets:
993
1072
  * --dir <dist> Required. Path to the pre-built dist/ directory.
@@ -1070,6 +1149,9 @@ async function main() {
1070
1149
  if (parsed.command === "create-record") {
1071
1150
  return await runCreateRecord(parsed.flags);
1072
1151
  }
1152
+ if (parsed.command === "invoke-rest") {
1153
+ return await runInvokeRest(parsed.flags);
1154
+ }
1073
1155
  if (parsed.command === "host-assets") {
1074
1156
  return await runHostAssets(parsed.flags);
1075
1157
  }
package/dist/client.d.ts CHANGED
@@ -43,6 +43,20 @@ export interface AttachmentMeta {
43
43
  hash?: string;
44
44
  size_bytes?: string;
45
45
  }
46
+ export type NowInvokeMethod = "GET" | "POST" | "PUT" | "DELETE";
47
+ export interface NowInvokeParams {
48
+ method: NowInvokeMethod;
49
+ /** Instance-relative path, e.g. /api/x_cadso_core/<service>/<resource>. */
50
+ path: string;
51
+ /** JSON request body for POST/PUT/DELETE. Ignored for GET. */
52
+ body?: unknown;
53
+ }
54
+ export interface NowInvokeResponse {
55
+ /** HTTP status code of the final response (after any 429/5xx retries). */
56
+ status: number;
57
+ /** Response body, verbatim. */
58
+ body: unknown;
59
+ }
46
60
  export interface ServiceNowClient {
47
61
  table: {
48
62
  /** GET /api/now/table/<t>?sysparm_query=...&sysparm_limit=N — returns result array. */
@@ -121,6 +135,21 @@ export interface ServiceNowClient {
121
135
  get: <T = any>(path: string) => Promise<T>;
122
136
  /** POST an arbitrary native ServiceNow REST path with a JSON body. See `get`. */
123
137
  post: <T = any>(path: string, body: any) => Promise<T>;
138
+ /** PUT an arbitrary native ServiceNow REST path with a JSON body. See `get`. */
139
+ put: <T = any>(path: string, body: any) => Promise<T>;
140
+ /** DELETE an arbitrary native ServiceNow REST path (optional JSON body). See `get`. */
141
+ delete: <T = any>(path: string, body?: any) => Promise<T>;
142
+ /**
143
+ * Invoke an arbitrary native ServiceNow REST path with an explicit method and
144
+ * get the HTTP response back verbatim as { status, body }. Unlike get/post/
145
+ * put/delete, non-2xx responses are RETURNED, not thrown, so callers (the
146
+ * dove-sn invoke-rest verb / invoke_rest MCP tool) can pass a Scripted REST
147
+ * operation's own error contract through faithfully. Same auth/retry/throttle
148
+ * transport: 429/5xx are retried per the client config and the LAST response
149
+ * is returned when retries are exhausted; only a network failure (no HTTP
150
+ * response at all) throws.
151
+ */
152
+ invoke: (params: NowInvokeParams) => Promise<NowInvokeResponse>;
124
153
  };
125
154
  attachment: {
126
155
  /**
package/dist/client.js CHANGED
@@ -110,7 +110,15 @@ function createClient(config = {}) {
110
110
  // Latch the legacy flag after the first 404 to avoid paying the round-trip
111
111
  // cost on every subsequent call.
112
112
  var useDovetailLegacyPath = false;
113
- async function request(cfg, ctx) {
113
+ /**
114
+ * Shared transport loop: throttle, send, retry 429/5xx, and surface auth/404
115
+ * failures as clear errors. With `passThrough`, any HTTP response that would
116
+ * normally throw (or that exhausted its retries) is RETURNED as
117
+ * { status, data } instead — only a network failure (no HTTP response at all)
118
+ * still throws. now.invoke uses passThrough to hand a Scripted REST
119
+ * operation's own error contract back verbatim.
120
+ */
121
+ async function requestRaw(cfg, ctx, passThrough) {
114
122
  var attempt429 = 0;
115
123
  var attempt5xx = 0;
116
124
  // eslint-disable-next-line no-constant-condition
@@ -132,14 +140,10 @@ function createClient(config = {}) {
132
140
  await sleep(Math.pow(2, attempt5xx) * 1000);
133
141
  continue;
134
142
  }
135
- if (res.status === 401 || res.status === 403) {
136
- throw new Error("SN auth error " + res.status + " on " + ctx + " — check SN_USER/SN_PASSWORD and ACLs.");
137
- }
138
- if (res.status === 404) {
139
- throw new Error("SN 404 on " + ctx + " — endpoint or record not found.");
140
- }
141
143
  if (res.status === 429) {
142
144
  if (attempt429 >= max429) {
145
+ if (passThrough)
146
+ return { status: res.status, data: res.data };
143
147
  throw new Error("SN 429 rate limit — retries exhausted on " + ctx);
144
148
  }
145
149
  attempt429 += 1;
@@ -148,19 +152,34 @@ function createClient(config = {}) {
148
152
  }
149
153
  if (res.status >= 500) {
150
154
  if (attempt5xx >= max5xx) {
155
+ if (passThrough)
156
+ return { status: res.status, data: res.data };
151
157
  throw new Error("SN " + res.status + " on " + ctx + " — retries exhausted.");
152
158
  }
153
159
  attempt5xx += 1;
154
160
  await sleep(Math.pow(2, attempt5xx) * 1000);
155
161
  continue;
156
162
  }
163
+ if (passThrough) {
164
+ return { status: res.status, data: res.data };
165
+ }
166
+ if (res.status === 401 || res.status === 403) {
167
+ throw new Error("SN auth error " + res.status + " on " + ctx + " — check SN_USER/SN_PASSWORD and ACLs.");
168
+ }
169
+ if (res.status === 404) {
170
+ throw new Error("SN 404 on " + ctx + " — endpoint or record not found.");
171
+ }
157
172
  if (res.status < 200 || res.status >= 300) {
158
173
  var body = typeof res.data === "string" ? res.data : JSON.stringify(res.data);
159
174
  throw new Error("SN " + res.status + " on " + ctx + ": " + body.substring(0, 400));
160
175
  }
161
- return res.data;
176
+ return { status: res.status, data: res.data };
162
177
  }
163
178
  }
179
+ async function request(cfg, ctx) {
180
+ var raw = await requestRaw(cfg, ctx);
181
+ return raw.data;
182
+ }
164
183
  // Dovetail core Scripted REST API request: try /api/cadso/dovetail_core/<op>,
165
184
  // fall back to the legacy /api/cadso/dovetail/<op> on 404 (one-time warning).
166
185
  async function dovetailRequest(method, op, body, params, ctx) {
@@ -315,6 +334,21 @@ function createClient(config = {}) {
315
334
  },
316
335
  post: function (path, body) {
317
336
  return request({ method: "POST", url: path, data: body }, "now.post(" + path + ")");
337
+ },
338
+ put: function (path, body) {
339
+ return request({ method: "PUT", url: path, data: body }, "now.put(" + path + ")");
340
+ },
341
+ delete: function (path, body) {
342
+ return request({ method: "DELETE", url: path, data: body }, "now.delete(" + path + ")");
343
+ },
344
+ invoke: async function (params) {
345
+ var cfg = { method: params.method, url: params.path };
346
+ // Per NowInvokeParams: body is ignored for GET — never attach a GET payload.
347
+ if (params.method !== "GET" && params.body !== undefined) {
348
+ cfg.data = params.body;
349
+ }
350
+ var raw = await requestRaw(cfg, "now.invoke(" + params.method + " " + params.path + ")", true);
351
+ return { status: raw.status, body: raw.data };
318
352
  }
319
353
  },
320
354
  attachment: {
package/dist/index.d.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  */
7
7
  export { createClient } from "./client";
8
8
  export { createClientFromEnvFile, resolveConfigFromEnvFile } from "./createClientFromEnvFile";
9
- export type { ServiceNowClient, TableQueryOptions, TableSchema, TableSchemaField, AttachmentMeta } from "./client";
9
+ export type { ServiceNowClient, TableQueryOptions, TableSchema, TableSchemaField, AttachmentMeta, NowInvokeMethod, NowInvokeParams, NowInvokeResponse } from "./client";
10
10
  export { addChoicesToField } from "./choices";
11
11
  export { hostAssets, classifyChunks, formatHostAssetsResult } from "./hostAssets";
12
12
  export { formatAddChoicesResult } from "./formatter";
@@ -25,3 +25,5 @@ export { setField } from "./setField";
25
25
  export { createRecord } from "./createRecord";
26
26
  export type { RecordWriteResult, SetFieldParams, SetFieldResult } from "./setField";
27
27
  export type { CreateRecordParams, CreateRecordResult } from "./createRecord";
28
+ export { invokeRest, INVOKE_REST_METHODS } from "./invokeRest";
29
+ export type { InvokeRestParams, InvokeRestResult } from "./invokeRest";
package/dist/index.js CHANGED
@@ -7,6 +7,7 @@
7
7
  */
8
8
  Object.defineProperty(exports, "__esModule", { value: true });
9
9
  exports.createRecord = exports.setField = exports.applyAddColumnOverlay = exports.deriveElement = exports.addColumn = exports.DEFAULT_SAVE_ACTION = exports.DEFAULT_SUPER_CLASS = exports.TYPE_MAP = exports.defaultAccessFlags = exports.applyTableSaveOverlay = exports.resolveType = exports.normalizeColumns = exports.buildColumnXml = exports.projectTableGraph = exports.createTable = exports.WriteOrderError = exports.executeWritePlan = exports.topoSort = exports.generateSysId = exports.DEFAULT_RUN_FLOW_PATH = exports.testFlow = exports.editFlow = exports.buildPublishModel = exports.createFlow = exports.copyFlow = exports.publishFlow = exports.readActionType = exports.readFlow = exports.editActionType = exports.publishActionType = exports.triggerPublication = exports.cloneActionType = exports.cloneSubflow = exports.verifyArtifact = exports.listTemplates = exports.sincPlugin = exports.formatCreateViewResult = exports.formatLayoutResult = exports.setRelatedLists = exports.setFormLayout = exports.setListLayout = exports.createView = exports.formatAddChoicesResult = exports.formatHostAssetsResult = exports.classifyChunks = exports.hostAssets = exports.addChoicesToField = exports.resolveConfigFromEnvFile = exports.createClientFromEnvFile = exports.createClient = void 0;
10
+ exports.INVOKE_REST_METHODS = exports.invokeRest = void 0;
10
11
  var client_1 = require("./client");
11
12
  Object.defineProperty(exports, "createClient", { enumerable: true, get: function () { return client_1.createClient; } });
12
13
  var createClientFromEnvFile_1 = require("./createClientFromEnvFile");
@@ -72,3 +73,6 @@ var setField_1 = require("./setField");
72
73
  Object.defineProperty(exports, "setField", { enumerable: true, get: function () { return setField_1.setField; } });
73
74
  var createRecord_1 = require("./createRecord");
74
75
  Object.defineProperty(exports, "createRecord", { enumerable: true, get: function () { return createRecord_1.createRecord; } });
76
+ var invokeRest_1 = require("./invokeRest");
77
+ Object.defineProperty(exports, "invokeRest", { enumerable: true, get: function () { return invokeRest_1.invokeRest; } });
78
+ Object.defineProperty(exports, "INVOKE_REST_METHODS", { enumerable: true, get: function () { return invokeRest_1.INVOKE_REST_METHODS; } });
@@ -0,0 +1,50 @@
1
+ /**
2
+ * dove-sn invoke-rest — invoke an arbitrary authenticated ServiceNow REST
3
+ * operation (an application's own Scripted REST endpoints included) with
4
+ * GET / POST / PUT / DELETE. The transport primitive behind the invoke_rest
5
+ * MCP tool (TenonHQ/Dovetail#212).
6
+ *
7
+ * DRY-RUN BY DEFAULT: without confirm:true the resolved method + path + body
8
+ * are echoed back and nothing is sent (no client, no credentials needed).
9
+ * On confirm the response comes back verbatim as { httpStatus, ok, body } —
10
+ * non-2xx responses are returned, not thrown, so a Scripted REST operation's
11
+ * own error contract passes through faithfully (the transport still retries
12
+ * 429/5xx first).
13
+ *
14
+ * PRIVACY: these endpoints carry caller payloads. This module never logs a
15
+ * request or response body — no console output, no debug files; bodies exist
16
+ * only in the returned result. Any log line about an invocation elsewhere must
17
+ * carry method, path and status ONLY.
18
+ */
19
+ import type { NowInvokeMethod, ServiceNowClient } from "./client";
20
+ export declare var INVOKE_REST_METHODS: Array<NowInvokeMethod>;
21
+ export interface InvokeRestParams {
22
+ /** Optional client injection (tests / reuse). Only resolved on the send path —
23
+ * a dry-run needs no credentials. */
24
+ client?: ServiceNowClient;
25
+ /** HTTP method — GET, POST, PUT or DELETE (case-insensitive). */
26
+ method: string;
27
+ /** Instance-relative path; must start with /api/ (e.g. /api/x_cadso_core/<service>/<resource>). */
28
+ path: string;
29
+ /** JSON request body for POST/PUT/DELETE. Rejected for GET. */
30
+ body?: unknown;
31
+ /** The send gate: the request is only sent when confirm is exactly true. */
32
+ confirm?: boolean;
33
+ /** Force a dry-run even when confirm is set. */
34
+ dryRun?: boolean;
35
+ }
36
+ export interface InvokeRestResult {
37
+ status: "dry-run" | "sent";
38
+ method: NowInvokeMethod;
39
+ path: string;
40
+ /** Echo of the request body (present when one was supplied) so the plan is auditable. */
41
+ requestBody?: unknown;
42
+ /** HTTP status of the response (sent only). */
43
+ httpStatus?: number;
44
+ /** True when httpStatus is 2xx (sent only). */
45
+ ok?: boolean;
46
+ /** Response body, verbatim (sent only). */
47
+ body?: unknown;
48
+ note: string;
49
+ }
50
+ export declare function invokeRest(params: InvokeRestParams): Promise<InvokeRestResult>;
@@ -0,0 +1,93 @@
1
+ "use strict";
2
+ /**
3
+ * dove-sn invoke-rest — invoke an arbitrary authenticated ServiceNow REST
4
+ * operation (an application's own Scripted REST endpoints included) with
5
+ * GET / POST / PUT / DELETE. The transport primitive behind the invoke_rest
6
+ * MCP tool (TenonHQ/Dovetail#212).
7
+ *
8
+ * DRY-RUN BY DEFAULT: without confirm:true the resolved method + path + body
9
+ * are echoed back and nothing is sent (no client, no credentials needed).
10
+ * On confirm the response comes back verbatim as { httpStatus, ok, body } —
11
+ * non-2xx responses are returned, not thrown, so a Scripted REST operation's
12
+ * own error contract passes through faithfully (the transport still retries
13
+ * 429/5xx first).
14
+ *
15
+ * PRIVACY: these endpoints carry caller payloads. This module never logs a
16
+ * request or response body — no console output, no debug files; bodies exist
17
+ * only in the returned result. Any log line about an invocation elsewhere must
18
+ * carry method, path and status ONLY.
19
+ */
20
+ Object.defineProperty(exports, "__esModule", { value: true });
21
+ exports.INVOKE_REST_METHODS = void 0;
22
+ exports.invokeRest = invokeRest;
23
+ const client_1 = require("./client");
24
+ exports.INVOKE_REST_METHODS = ["GET", "POST", "PUT", "DELETE"];
25
+ function normalizeMethod(method) {
26
+ var raw = typeof method === "string" ? method.toUpperCase() : "";
27
+ if (exports.INVOKE_REST_METHODS.indexOf(raw) === -1) {
28
+ throw new Error("invoke-rest: method must be one of GET, POST, PUT, DELETE (got '"
29
+ + String(method) + "').");
30
+ }
31
+ return raw;
32
+ }
33
+ function validatePath(path) {
34
+ if (typeof path !== "string" || path.length === 0) {
35
+ throw new Error("invoke-rest: path is required.");
36
+ }
37
+ if (path.indexOf("/api/") !== 0) {
38
+ throw new Error("invoke-rest: path must be instance-relative and start with /api/ (got '"
39
+ + path + "'). Absolute URLs are not accepted — the client's base URL supplies the instance.");
40
+ }
41
+ if (/\s/.test(path)) {
42
+ throw new Error("invoke-rest: path must not contain whitespace — URL-encode query values.");
43
+ }
44
+ return path;
45
+ }
46
+ async function invokeRest(params) {
47
+ if (!params || typeof params !== "object") {
48
+ throw new Error("invoke-rest: params object is required.");
49
+ }
50
+ var method = normalizeMethod(params.method);
51
+ var path = validatePath(params.path);
52
+ if (method === "GET" && params.body !== undefined) {
53
+ throw new Error("invoke-rest: a request body is not allowed with GET.");
54
+ }
55
+ var send = params.confirm === true && params.dryRun !== true;
56
+ if (!send) {
57
+ var dry = {
58
+ status: "dry-run",
59
+ method: method,
60
+ path: path,
61
+ note: "dry-run (the default): nothing was sent. Would " + method + " " + path
62
+ + (params.body !== undefined ? " with the supplied JSON body" : " with no body")
63
+ + ". Re-run with confirm to send."
64
+ };
65
+ if (params.body !== undefined) {
66
+ dry.requestBody = params.body;
67
+ }
68
+ return dry;
69
+ }
70
+ // Client is resolved only here so a dry-run never needs credentials.
71
+ var client = params.client || (0, client_1.createClient)({});
72
+ var response = await client.now.invoke({ method: method, path: path, body: params.body });
73
+ var ok = response.status >= 200 && response.status < 300;
74
+ var result = {
75
+ status: "sent",
76
+ method: method,
77
+ path: path,
78
+ httpStatus: response.status,
79
+ ok: ok,
80
+ body: response.body,
81
+ note: ok
82
+ ? "Sent " + method + " " + path + " — HTTP " + response.status + "."
83
+ : "Sent " + method + " " + path + " — HTTP " + response.status
84
+ + " (non-2xx returned verbatim"
85
+ + (response.status === 401 || response.status === 403
86
+ ? "; if unexpected, check SN_USER/SN_PASSWORD and ACLs" : "")
87
+ + ")."
88
+ };
89
+ if (params.body !== undefined) {
90
+ result.requestBody = params.body;
91
+ }
92
+ return result;
93
+ }
@@ -10,7 +10,7 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
10
10
  import { z } from "zod";
11
11
  import type { ToolAnnotations } from "@tenonhq/dovetail-mcp-kit";
12
12
  import type { ServiceNowClient } from "../client";
13
- export declare var TOOL_NAMES: readonly ["create_view", "set_list_layout", "set_form_layout", "set_related_lists", "add_choices_to_field", "flow_view", "action_view", "flow_publish", "flow_copy", "flow_create", "flow_test", "flow_edit", "create_table", "add_column", "set_field", "create_record", "host_assets"];
13
+ export declare var TOOL_NAMES: readonly ["create_view", "set_list_layout", "set_form_layout", "set_related_lists", "add_choices_to_field", "flow_view", "action_view", "flow_publish", "flow_copy", "flow_create", "flow_test", "flow_edit", "create_table", "add_column", "set_field", "create_record", "host_assets", "invoke_rest"];
14
14
  export type ToolName = typeof TOOL_NAMES[number];
15
15
  export interface RegistryDeps {
16
16
  /** Optional client injection for tests; defaults to createClient({}). */
@@ -29,6 +29,7 @@ const table_1 = require("../table");
29
29
  const hostAssets_1 = require("../hostAssets");
30
30
  const setField_1 = require("../setField");
31
31
  const createRecord_1 = require("../createRecord");
32
+ const invokeRest_1 = require("../invokeRest");
32
33
  const schemas_1 = require("./schemas");
33
34
  exports.TOOL_NAMES = [
34
35
  "create_view",
@@ -47,7 +48,8 @@ exports.TOOL_NAMES = [
47
48
  "add_column",
48
49
  "set_field",
49
50
  "create_record",
50
- "host_assets"
51
+ "host_assets",
52
+ "invoke_rest"
51
53
  ];
52
54
  // Annotation presets (READ_ONLY / WRITE_ADDITIVE_IDEMPOTENT / WRITE_CREATE /
53
55
  // WRITE_OVERWRITE / WRITE_EXECUTE) come from @tenonhq/dovetail-mcp-kit.
@@ -367,6 +369,38 @@ function buildDescriptors(deps = {}) {
367
369
  handler: async function (args) {
368
370
  return (0, hostAssets_1.hostAssets)(client(), schemas_1.hostAssetsSchema.parse(args));
369
371
  }
372
+ },
373
+ {
374
+ name: "invoke_rest",
375
+ annotations: dovetail_mcp_kit_1.WRITE_EXECUTE,
376
+ description: "Invoke an arbitrary authenticated ServiceNow REST operation — including an application's "
377
+ + "own Scripted REST endpoints (/api/<scope>/<service>/<resource>) — with GET, POST, PUT or "
378
+ + "DELETE. A transport primitive: it can drive update and DELETE operations, so it is "
379
+ + "destructive-capable and non-idempotent. DRY-RUN BY DEFAULT — without confirm:true nothing "
380
+ + "is sent and the resolved method + path + body are echoed back; dryRun:true forces a "
381
+ + "dry-run even with confirm. On send, returns { httpStatus, ok, body } with the response "
382
+ + "passed through verbatim — non-2xx responses are returned, not thrown, so the operation's "
383
+ + "own error contract is preserved (429/5xx are retried by the transport first). path must "
384
+ + "be instance-relative and start with /api/. Request/response bodies are never logged — "
385
+ + "they exist only in this result. For sys_* / x_* record CRUD use set_field / "
386
+ + "create_record instead; this tool is for operations those fixed verbs cannot express.",
387
+ shape: schemas_1.invokeRestSchema.shape,
388
+ handler: async function (args) {
389
+ var p = schemas_1.invokeRestSchema.parse(args);
390
+ var params = {
391
+ method: p.method,
392
+ path: p.path,
393
+ body: p.body,
394
+ confirm: p.confirm,
395
+ dryRun: p.dryRun
396
+ };
397
+ // Client resolution is lazy: a dry-run needs no credentials, so only
398
+ // attach one when injected (tests) — invokeRest creates its own on send.
399
+ if (deps.client) {
400
+ params.client = deps.client;
401
+ }
402
+ return (0, invokeRest_1.invokeRest)(params);
403
+ }
370
404
  }
371
405
  ];
372
406
  }
@@ -607,3 +607,22 @@ export declare var createRecordSchema: z.ZodObject<{
607
607
  dryRun?: boolean | undefined;
608
608
  ifAbsentQuery?: string | undefined;
609
609
  }>;
610
+ export declare var invokeRestSchema: z.ZodObject<{
611
+ method: z.ZodEffects<z.ZodEnum<["GET", "POST", "PUT", "DELETE"]>, "GET" | "DELETE" | "POST" | "PUT", unknown>;
612
+ path: z.ZodString;
613
+ body: z.ZodOptional<z.ZodUnknown>;
614
+ confirm: z.ZodOptional<z.ZodBoolean>;
615
+ dryRun: z.ZodOptional<z.ZodBoolean>;
616
+ }, "strip", z.ZodTypeAny, {
617
+ path: string;
618
+ method: "GET" | "DELETE" | "POST" | "PUT";
619
+ body?: unknown;
620
+ confirm?: boolean | undefined;
621
+ dryRun?: boolean | undefined;
622
+ }, {
623
+ path: string;
624
+ body?: unknown;
625
+ method?: unknown;
626
+ confirm?: boolean | undefined;
627
+ dryRun?: boolean | undefined;
628
+ }>;
@@ -4,7 +4,7 @@
4
4
  * own file so registry.ts stays focused on wiring.
5
5
  */
6
6
  Object.defineProperty(exports, "__esModule", { value: true });
7
- exports.createRecordSchema = exports.setFieldSchema = exports.addColumnSchema = exports.createTableSchema = exports.columnSpecSchema = exports.hostAssetsSchema = exports.editFlowSchema = exports.stepInputPatchSchema = exports.testFlowSchema = exports.createFlowSchema = exports.copyFlowSchema = exports.publishFlowSchema = exports.viewActionSchema = exports.viewFlowSchema = exports.addChoicesToFieldSchema = exports.choiceValueSchema = exports.setRelatedListsSchema = exports.setFormLayoutSchema = exports.formSectionSchema = exports.setListLayoutSchema = exports.createViewSchema = void 0;
7
+ exports.invokeRestSchema = exports.createRecordSchema = exports.setFieldSchema = exports.addColumnSchema = exports.createTableSchema = exports.columnSpecSchema = exports.hostAssetsSchema = exports.editFlowSchema = exports.stepInputPatchSchema = exports.testFlowSchema = exports.createFlowSchema = exports.copyFlowSchema = exports.publishFlowSchema = exports.viewActionSchema = exports.viewFlowSchema = exports.addChoicesToFieldSchema = exports.choiceValueSchema = exports.setRelatedListsSchema = exports.setFormLayoutSchema = exports.formSectionSchema = exports.setListLayoutSchema = exports.createViewSchema = void 0;
8
8
  const zod_1 = require("zod");
9
9
  exports.createViewSchema = zod_1.z.object({
10
10
  name: zod_1.z.string().min(1),
@@ -176,3 +176,13 @@ exports.createRecordSchema = zod_1.z.object({
176
176
  ifAbsentQuery: zod_1.z.string().optional(),
177
177
  dryRun: zod_1.z.boolean().optional()
178
178
  });
179
+ // invoke_rest: transport primitive for arbitrary authenticated REST operations
180
+ // (Scripted REST included). The dry-run-unless-confirm gate lives in invokeRest
181
+ // itself; the regex here rejects absolute URLs and non-/api/ paths early.
182
+ exports.invokeRestSchema = zod_1.z.object({
183
+ method: zod_1.z.preprocess(function (v) { return typeof v === "string" ? v.toUpperCase() : v; }, zod_1.z.enum(["GET", "POST", "PUT", "DELETE"])),
184
+ path: zod_1.z.string().min(1).regex(/^\/api\//, "path must be instance-relative and start with /api/"),
185
+ body: zod_1.z.unknown().optional(),
186
+ confirm: zod_1.z.boolean().optional(),
187
+ dryRun: zod_1.z.boolean().optional()
188
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tenonhq/dovetail-servicenow",
3
- "version": "0.0.25",
3
+ "version": "0.0.26",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },