@salesforce/graphiti 10.15.1 → 10.17.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 +10 -0
- package/dist/cli.js +21 -1
- package/dist/cli.js.map +1 -1
- package/dist/commands/mcp-mirror/commands.d.ts +16 -0
- package/dist/commands/mcp-mirror/commands.js +38 -0
- package/dist/commands/mcp-mirror/commands.js.map +1 -0
- package/dist/commands/mcp-mirror/run-mirror.d.ts +42 -0
- package/dist/commands/mcp-mirror/run-mirror.js +105 -0
- package/dist/commands/mcp-mirror/run-mirror.js.map +1 -0
- package/dist/lib/graphql-name.d.ts +2 -2
- package/dist/lib/graphql-name.js +2 -2
- package/dist/mcp/stdio.js +0 -0
- package/dist/mcp/tools/sf-gql-aggregate.js +2 -55
- package/dist/mcp/tools/sf-gql-aggregate.js.map +1 -1
- package/dist/mcp/tools/sf-gql-connect.js +2 -9
- package/dist/mcp/tools/sf-gql-connect.js.map +1 -1
- package/dist/mcp/tools/sf-gql-create.js +2 -15
- package/dist/mcp/tools/sf-gql-create.js.map +1 -1
- package/dist/mcp/tools/sf-gql-delete.js +2 -11
- package/dist/mcp/tools/sf-gql-delete.js.map +1 -1
- package/dist/mcp/tools/sf-gql-detail.js +2 -30
- package/dist/mcp/tools/sf-gql-detail.js.map +1 -1
- package/dist/mcp/tools/sf-gql-discover.js +2 -33
- package/dist/mcp/tools/sf-gql-discover.js.map +1 -1
- package/dist/mcp/tools/sf-gql-list.js +2 -32
- package/dist/mcp/tools/sf-gql-list.js.map +1 -1
- package/dist/mcp/tools/sf-gql-raw.js +2 -19
- package/dist/mcp/tools/sf-gql-raw.js.map +1 -1
- package/dist/mcp/tools/sf-gql-update.js +2 -15
- package/dist/mcp/tools/sf-gql-update.js.map +1 -1
- package/dist/{mcp/tools/shared/zod-schemas.js → schemas/fields.js} +2 -2
- package/dist/schemas/fields.js.map +1 -0
- package/dist/schemas/input-schemas.d.ts +318 -0
- package/dist/schemas/input-schemas.js +230 -0
- package/dist/schemas/input-schemas.js.map +1 -0
- package/package.json +1 -1
- package/src/cli.ts +23 -1
- package/src/commands/mcp-mirror/__tests__/commands.spec.ts +146 -0
- package/src/commands/mcp-mirror/__tests__/run-mirror.spec.ts +251 -0
- package/src/commands/mcp-mirror/commands.ts +92 -0
- package/src/commands/mcp-mirror/run-mirror.ts +149 -0
- package/src/lib/graphql-name.ts +2 -2
- package/src/mcp/tools/sf-gql-aggregate.ts +2 -68
- package/src/mcp/tools/sf-gql-connect.ts +2 -11
- package/src/mcp/tools/sf-gql-create.ts +2 -21
- package/src/mcp/tools/sf-gql-delete.ts +2 -15
- package/src/mcp/tools/sf-gql-detail.ts +2 -42
- package/src/mcp/tools/sf-gql-discover.ts +2 -36
- package/src/mcp/tools/sf-gql-list.ts +2 -41
- package/src/mcp/tools/sf-gql-raw.ts +2 -25
- package/src/mcp/tools/sf-gql-update.ts +2 -21
- package/src/{mcp/tools/shared/zod-schemas.ts → schemas/fields.ts} +1 -1
- package/src/schemas/input-schemas.ts +305 -0
- package/dist/mcp/tools/shared/zod-schemas.js.map +0 -1
- package/dist/{mcp/tools/shared/zod-schemas.d.ts → schemas/fields.d.ts} +2 -2
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026, Salesforce, Inc.,
|
|
3
|
+
* All rights reserved.
|
|
4
|
+
* For full license text, see the LICENSE.txt file
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { captureStdout } from "../../../__tests__/helpers/stdout.js";
|
|
10
|
+
import { SchemaRefreshError } from "../../../lib/prime-schema.js";
|
|
11
|
+
import { runMirror } from "../run-mirror.js";
|
|
12
|
+
|
|
13
|
+
const SCHEMA = z.object({ org: z.string().min(1), object: z.string() });
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
// runMirror signals failure via process.exitCode; establish a known 0
|
|
17
|
+
// baseline so "success leaves it 0" is deterministic and a prior failing
|
|
18
|
+
// case doesn't leak into the next.
|
|
19
|
+
process.exitCode = 0;
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("runMirror", () => {
|
|
23
|
+
it("prints the build output as one JSON line on success and leaves exitCode 0", async () => {
|
|
24
|
+
const build = async (input: { org: string; object: string }) => ({
|
|
25
|
+
query: `# ${input.object}`,
|
|
26
|
+
echoedOrg: input.org,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const out = await captureStdout(() =>
|
|
30
|
+
runMirror('{"org":"ebikes","object":"Account"}', SCHEMA, build),
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
expect(out.trim().split("\n")).toHaveLength(1);
|
|
34
|
+
expect(JSON.parse(out)).toEqual({ query: "# Account", echoedOrg: "ebikes" });
|
|
35
|
+
expect(process.exitCode).toBe(0);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("passes the parsed+validated input to the build function", async () => {
|
|
39
|
+
let received: unknown;
|
|
40
|
+
const build = async (input: unknown) => {
|
|
41
|
+
received = input;
|
|
42
|
+
return {};
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
await captureStdout(() => runMirror('{"org":"o","object":"Account"}', SCHEMA, build));
|
|
46
|
+
|
|
47
|
+
expect(received).toEqual({ org: "o", object: "Account" });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("emits an INVALID_ARGS envelope and sets exitCode 1 on malformed JSON", async () => {
|
|
51
|
+
const build = async () => ({});
|
|
52
|
+
|
|
53
|
+
const out = await captureStdout(() => runMirror("{not json", SCHEMA, build));
|
|
54
|
+
|
|
55
|
+
const parsed = JSON.parse(out);
|
|
56
|
+
expect(parsed.error.code).toBe("INVALID_ARGS");
|
|
57
|
+
expect(parsed.error.message).toBeTruthy();
|
|
58
|
+
expect(process.exitCode).toBe(1);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("emits an INVALID_ARGS envelope with Zod details on schema-validation failure", async () => {
|
|
62
|
+
const build = async () => ({});
|
|
63
|
+
|
|
64
|
+
// org is "" → fails min(1); object missing
|
|
65
|
+
const out = await captureStdout(() => runMirror('{"org":""}', SCHEMA, build));
|
|
66
|
+
|
|
67
|
+
const parsed = JSON.parse(out);
|
|
68
|
+
expect(parsed.error.code).toBe("INVALID_ARGS");
|
|
69
|
+
expect(parsed.error.details).toBeTruthy();
|
|
70
|
+
expect(process.exitCode).toBe(1);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("does not call build when validation fails", async () => {
|
|
74
|
+
let called = false;
|
|
75
|
+
const build = async () => {
|
|
76
|
+
called = true;
|
|
77
|
+
return {};
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
await captureStdout(() => runMirror('{"org":""}', SCHEMA, build));
|
|
81
|
+
|
|
82
|
+
expect(called).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("reads JSON from stdin when no positional arg is given", async () => {
|
|
86
|
+
const build = async (input: { org: string; object: string }) => ({ org: input.org });
|
|
87
|
+
|
|
88
|
+
const out = await captureStdout(() =>
|
|
89
|
+
runMirror(undefined, SCHEMA, build, {
|
|
90
|
+
readStdin: async () => '{"org":"from-stdin","object":"Case"}',
|
|
91
|
+
}),
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
expect(JSON.parse(out)).toEqual({ org: "from-stdin" });
|
|
95
|
+
expect(process.exitCode).toBe(0);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("emits INVALID_ARGS without hanging when no arg is given and stdin is a TTY", async () => {
|
|
99
|
+
let stdinRead = false;
|
|
100
|
+
const build = async () => ({});
|
|
101
|
+
|
|
102
|
+
const out = await captureStdout(() =>
|
|
103
|
+
runMirror(undefined, SCHEMA, build, {
|
|
104
|
+
isTTY: true,
|
|
105
|
+
readStdin: async () => {
|
|
106
|
+
stdinRead = true;
|
|
107
|
+
return "";
|
|
108
|
+
},
|
|
109
|
+
}),
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
expect(stdinRead).toBe(false); // guarded before touching stdin
|
|
113
|
+
expect(JSON.parse(out).error.code).toBe("INVALID_ARGS");
|
|
114
|
+
expect(process.exitCode).toBe(1);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("maps SchemaRefreshError to SCHEMA_PRIME_FAILED", async () => {
|
|
118
|
+
const build = async () => {
|
|
119
|
+
throw new SchemaRefreshError("schema is stale", {
|
|
120
|
+
instanceUrl: "https://x.my.salesforce.com",
|
|
121
|
+
});
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const out = await captureStdout(() =>
|
|
125
|
+
runMirror('{"org":"o","object":"Account"}', SCHEMA, build),
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
const parsed = JSON.parse(out);
|
|
129
|
+
expect(parsed.error.code).toBe("SCHEMA_PRIME_FAILED");
|
|
130
|
+
expect(parsed.error.message).toContain("stale");
|
|
131
|
+
expect(process.exitCode).toBe(1);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("maps an unrecognized thrown error to INTERNAL and preserves its message", async () => {
|
|
135
|
+
const build = async () => {
|
|
136
|
+
throw new Error("something totally unexpected happened");
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const out = await captureStdout(() =>
|
|
140
|
+
runMirror('{"org":"o","object":"Account"}', SCHEMA, build),
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
const parsed = JSON.parse(out);
|
|
144
|
+
expect(parsed.error.code).toBe("INTERNAL");
|
|
145
|
+
expect(parsed.error.message).toContain("something totally unexpected");
|
|
146
|
+
expect(process.exitCode).toBe(1);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("best-effort classifies auth-shaped messages as AUTH_FAILED", async () => {
|
|
150
|
+
const build = async () => {
|
|
151
|
+
throw new Error("Authentication failed; no valid token for org");
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const out = await captureStdout(() =>
|
|
155
|
+
runMirror('{"org":"o","object":"Account"}', SCHEMA, build),
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
const parsed = JSON.parse(out);
|
|
159
|
+
expect(parsed.error.code).toBe("AUTH_FAILED");
|
|
160
|
+
expect(parsed.error.message).toContain("Authentication failed");
|
|
161
|
+
expect(process.exitCode).toBe(1);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("best-effort classifies a ~/.sf path message as AUTH_FAILED", async () => {
|
|
165
|
+
const build = async () => {
|
|
166
|
+
throw new Error("Could not read credentials from ~/.sf");
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const out = await captureStdout(() =>
|
|
170
|
+
runMirror('{"org":"o","object":"Account"}', SCHEMA, build),
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
expect(JSON.parse(out).error.code).toBe("AUTH_FAILED");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("best-effort classifies introspection/priming messages as SCHEMA_PRIME_FAILED", async () => {
|
|
177
|
+
const build = async () => {
|
|
178
|
+
throw new Error("schema download failed during introspection");
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const out = await captureStdout(() =>
|
|
182
|
+
runMirror('{"org":"o","object":"Account"}', SCHEMA, build),
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
expect(JSON.parse(out).error.code).toBe("SCHEMA_PRIME_FAILED");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("still prefers the typed SchemaRefreshError signal over message regex", async () => {
|
|
189
|
+
// A SchemaRefreshError whose message happens to contain an auth-ish word
|
|
190
|
+
// must still classify by type, not by the regex.
|
|
191
|
+
const build = async () => {
|
|
192
|
+
throw new SchemaRefreshError("stale; re-authorization may help", {
|
|
193
|
+
instanceUrl: "https://x.my.salesforce.com",
|
|
194
|
+
});
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const out = await captureStdout(() =>
|
|
198
|
+
runMirror('{"org":"o","object":"Account"}', SCHEMA, build),
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
expect(JSON.parse(out).error.code).toBe("SCHEMA_PRIME_FAILED");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("treats a literal '-' positional arg as 'read from stdin'", async () => {
|
|
205
|
+
const build = async (input: { org: string; object: string }) => ({ org: input.org });
|
|
206
|
+
|
|
207
|
+
const out = await captureStdout(() =>
|
|
208
|
+
runMirror("-", SCHEMA, build, {
|
|
209
|
+
readStdin: async () => '{"org":"via-dash","object":"Case"}',
|
|
210
|
+
}),
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
expect(JSON.parse(out)).toEqual({ org: "via-dash" });
|
|
214
|
+
expect(process.exitCode).toBe(0);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("does not leak a stack trace in the INTERNAL envelope by default", async () => {
|
|
218
|
+
const build = async () => {
|
|
219
|
+
throw new Error("boom");
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const out = await captureStdout(() =>
|
|
223
|
+
runMirror('{"org":"o","object":"Account"}', SCHEMA, build),
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
const parsed = JSON.parse(out);
|
|
227
|
+
expect(parsed.error.code).toBe("INTERNAL");
|
|
228
|
+
expect(parsed.error.details).toBeUndefined();
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("attaches the stack to INTERNAL details when GRAPHITI_DEBUG=1", async () => {
|
|
232
|
+
const prev = process.env.GRAPHITI_DEBUG;
|
|
233
|
+
process.env.GRAPHITI_DEBUG = "1";
|
|
234
|
+
try {
|
|
235
|
+
const build = async () => {
|
|
236
|
+
throw new Error("boom");
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
const out = await captureStdout(() =>
|
|
240
|
+
runMirror('{"org":"o","object":"Account"}', SCHEMA, build),
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
const parsed = JSON.parse(out);
|
|
244
|
+
expect(parsed.error.code).toBe("INTERNAL");
|
|
245
|
+
expect(parsed.error.details.stack).toContain("boom");
|
|
246
|
+
} finally {
|
|
247
|
+
if (prev === undefined) delete process.env.GRAPHITI_DEBUG;
|
|
248
|
+
else process.env.GRAPHITI_DEBUG = prev;
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026, Salesforce, Inc.,
|
|
3
|
+
* All rights reserved.
|
|
4
|
+
* For full license text, see the LICENSE.txt file
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* CLI mirror commands — one entry per MCP tool. Each binds a Zod input schema
|
|
9
|
+
* to the matching `intent/build*()` function; all the real logic lives in
|
|
10
|
+
* `runMirror` (`run-mirror.ts`). The commands are siblings of the MCP tools: same
|
|
11
|
+
* args, same output, different transport.
|
|
12
|
+
*
|
|
13
|
+
* The bindings are data, not bespoke functions — `cli.ts` registers them in a
|
|
14
|
+
* loop and the tests iterate them, so adding a tool is one `defineMirror(...)`
|
|
15
|
+
* line here.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { type z } from "zod";
|
|
19
|
+
import { runMirror } from "./run-mirror.js";
|
|
20
|
+
import { buildAggregate } from "../../intent/build-aggregate.js";
|
|
21
|
+
import { buildConnect } from "../../intent/build-connect.js";
|
|
22
|
+
import { buildCreate } from "../../intent/build-create.js";
|
|
23
|
+
import { buildDelete } from "../../intent/build-delete.js";
|
|
24
|
+
import { buildDetail } from "../../intent/build-detail.js";
|
|
25
|
+
import { buildDiscover } from "../../intent/build-discover.js";
|
|
26
|
+
import { buildList } from "../../intent/build-list.js";
|
|
27
|
+
import { buildRaw } from "../../intent/build-raw.js";
|
|
28
|
+
import { buildUpdate } from "../../intent/build-update.js";
|
|
29
|
+
import {
|
|
30
|
+
AGGREGATE_INPUT,
|
|
31
|
+
CONNECT_INPUT,
|
|
32
|
+
CREATE_INPUT,
|
|
33
|
+
DELETE_INPUT,
|
|
34
|
+
DETAIL_INPUT,
|
|
35
|
+
DISCOVER_INPUT,
|
|
36
|
+
LIST_INPUT,
|
|
37
|
+
RAW_INPUT,
|
|
38
|
+
UPDATE_INPUT,
|
|
39
|
+
} from "../../schemas/input-schemas.js";
|
|
40
|
+
|
|
41
|
+
/** A single CLI mirror command: its subcommand name, help summary, and runner. */
|
|
42
|
+
export interface MirrorCommand {
|
|
43
|
+
/** CLI subcommand name, e.g. "sf-gql-list", matching the MCP tool's `sf_gql_*`. */
|
|
44
|
+
name: string;
|
|
45
|
+
/** One-line `--help` summary. */
|
|
46
|
+
summary: string;
|
|
47
|
+
/** Read JSON (positional arg or stdin), validate, build, emit one JSON line. */
|
|
48
|
+
run: (jsonArg?: string) => Promise<void>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Bind a schema to its builder behind a uniform `run`. The generic `T` is
|
|
53
|
+
* captured here, at construction, where the schema and builder types still line
|
|
54
|
+
* up — so {@link MIRRORS} can be a single homogeneous array without forcing the
|
|
55
|
+
* builders through an `unknown` parameter (which would fail by contravariance).
|
|
56
|
+
*/
|
|
57
|
+
function defineMirror<T>(
|
|
58
|
+
name: string,
|
|
59
|
+
summary: string,
|
|
60
|
+
schema: z.ZodType<T>,
|
|
61
|
+
build: (input: T) => Promise<unknown>,
|
|
62
|
+
): MirrorCommand {
|
|
63
|
+
return { name, summary, run: (jsonArg) => runMirror(jsonArg, schema, build) };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Every CLI mirror command. Single source of truth for `cli.ts` and the tests. */
|
|
67
|
+
export const MIRRORS: MirrorCommand[] = [
|
|
68
|
+
defineMirror("sf-gql-list", "Build a UIAPI list query.", LIST_INPUT, buildList),
|
|
69
|
+
defineMirror("sf-gql-detail", "Build a single-record detail query.", DETAIL_INPUT, buildDetail),
|
|
70
|
+
defineMirror("sf-gql-discover", "Discover UIAPI schema metadata.", DISCOVER_INPUT, buildDiscover),
|
|
71
|
+
defineMirror(
|
|
72
|
+
"sf-gql-aggregate",
|
|
73
|
+
"Build a UIAPI aggregate query.",
|
|
74
|
+
AGGREGATE_INPUT,
|
|
75
|
+
buildAggregate,
|
|
76
|
+
),
|
|
77
|
+
defineMirror(
|
|
78
|
+
"sf-gql-raw",
|
|
79
|
+
"Build an arbitrary query from CLI-style commands.",
|
|
80
|
+
RAW_INPUT,
|
|
81
|
+
buildRaw,
|
|
82
|
+
),
|
|
83
|
+
defineMirror("sf-gql-create", "Build a UIAPI create mutation.", CREATE_INPUT, buildCreate),
|
|
84
|
+
defineMirror("sf-gql-update", "Build a UIAPI update mutation.", UPDATE_INPUT, buildUpdate),
|
|
85
|
+
defineMirror("sf-gql-delete", "Build a UIAPI delete mutation.", DELETE_INPUT, buildDelete),
|
|
86
|
+
defineMirror(
|
|
87
|
+
"sf-gql-connect",
|
|
88
|
+
"Prime/refresh an org's schema cache.",
|
|
89
|
+
CONNECT_INPUT,
|
|
90
|
+
buildConnect,
|
|
91
|
+
),
|
|
92
|
+
];
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026, Salesforce, Inc.,
|
|
3
|
+
* All rights reserved.
|
|
4
|
+
* For full license text, see the LICENSE.txt file
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { type z } from "zod";
|
|
8
|
+
import { SchemaRefreshError } from "../../lib/prime-schema.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Shared adapter for the `sf-gql-*` CLI mirror commands. Each command reads a
|
|
12
|
+
* JSON blob (positional arg or stdin), validates it against the same Zod schema
|
|
13
|
+
* the matching MCP tool advertises, calls the same `intent/build*()` function
|
|
14
|
+
* the MCP tool calls, and emits exactly one JSON line on stdout. This keeps the
|
|
15
|
+
* CLI a sibling adapter to the MCP server — same args, same output, different
|
|
16
|
+
* transport.
|
|
17
|
+
*
|
|
18
|
+
* Failures emit a JSON error envelope and set `process.exitCode = 1`. We use
|
|
19
|
+
* `process.exitCode` rather than `process.exit()` so the buffered stdout write
|
|
20
|
+
* has time to flush before the process ends.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** Error codes surfaced in the failure envelope. */
|
|
24
|
+
export type MirrorErrorCode = "INVALID_ARGS" | "AUTH_FAILED" | "SCHEMA_PRIME_FAILED" | "INTERNAL";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Best-effort classification of a plain `Error` message into a more specific
|
|
28
|
+
* code. The intent/auth layers throw plain `Error`s with no type signal, so the
|
|
29
|
+
* only available hint is the message text. This is a NON-CONTRACT heuristic: a
|
|
30
|
+
* reworded upstream message can fall through to `INTERNAL`. The verbatim message
|
|
31
|
+
* is always preserved either way, and the typed `SchemaRefreshError` signal
|
|
32
|
+
* (checked before this) takes precedence. Patterns mirror the MCP-CLI prior art.
|
|
33
|
+
*/
|
|
34
|
+
function classifyErrorMessage(message: string): MirrorErrorCode {
|
|
35
|
+
if (
|
|
36
|
+
/\bauth(?:entication|orization)?\b/i.test(message) ||
|
|
37
|
+
/\bno org\b/i.test(message) ||
|
|
38
|
+
message.includes("~/.sf")
|
|
39
|
+
) {
|
|
40
|
+
return "AUTH_FAILED";
|
|
41
|
+
}
|
|
42
|
+
if (/\b(?:introspect(?:ion)?|priming|schema download)\b/i.test(message)) {
|
|
43
|
+
return "SCHEMA_PRIME_FAILED";
|
|
44
|
+
}
|
|
45
|
+
return "INTERNAL";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface MirrorErrorEnvelope {
|
|
49
|
+
error: {
|
|
50
|
+
code: MirrorErrorCode;
|
|
51
|
+
message: string;
|
|
52
|
+
details?: unknown;
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface RunMirrorDeps {
|
|
57
|
+
/** Read the full JSON payload from stdin. Injectable for tests. */
|
|
58
|
+
readStdin?: () => Promise<string>;
|
|
59
|
+
/** Whether stdin is a TTY. When true and no arg is given, we don't block on stdin. */
|
|
60
|
+
isTTY?: boolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function readAllStdin(): Promise<string> {
|
|
64
|
+
const chunks: Buffer[] = [];
|
|
65
|
+
for await (const chunk of process.stdin) {
|
|
66
|
+
chunks.push(chunk as Buffer);
|
|
67
|
+
}
|
|
68
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function emitError(code: MirrorErrorCode, message: string, details?: unknown): void {
|
|
72
|
+
const envelope: MirrorErrorEnvelope = {
|
|
73
|
+
error: details === undefined ? { code, message } : { code, message, details },
|
|
74
|
+
};
|
|
75
|
+
console.log(JSON.stringify(envelope));
|
|
76
|
+
// Not process.exit(1): let the buffered stdout write flush first.
|
|
77
|
+
process.exitCode = 1;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Run one mirror command end-to-end.
|
|
82
|
+
*
|
|
83
|
+
* @param jsonArg Positional JSON argument, or undefined to read from stdin.
|
|
84
|
+
* @param schema The tool's Zod input schema (shared with the MCP tool).
|
|
85
|
+
* @param build The intent-layer builder; receives the validated input.
|
|
86
|
+
* @param deps Injectable stdin reader / TTY flag for testing.
|
|
87
|
+
*/
|
|
88
|
+
export async function runMirror<T>(
|
|
89
|
+
jsonArg: string | undefined,
|
|
90
|
+
schema: z.ZodType<T>,
|
|
91
|
+
build: (input: T) => Promise<unknown>,
|
|
92
|
+
deps: RunMirrorDeps = {},
|
|
93
|
+
): Promise<void> {
|
|
94
|
+
// 1. Source the raw JSON: a positional arg wins, except a literal "-" which
|
|
95
|
+
// is the conventional "read from stdin" sentinel. Otherwise read stdin
|
|
96
|
+
// (unless TTY, where blocking would hang waiting for a human to type).
|
|
97
|
+
const isTTY = deps.isTTY ?? process.stdin.isTTY;
|
|
98
|
+
const wantsStdin = jsonArg === undefined || jsonArg === "-";
|
|
99
|
+
let raw: string;
|
|
100
|
+
if (!wantsStdin) {
|
|
101
|
+
raw = jsonArg as string;
|
|
102
|
+
} else if (isTTY) {
|
|
103
|
+
emitError("INVALID_ARGS", "No input provided. Pass a JSON argument or pipe JSON to stdin.");
|
|
104
|
+
return;
|
|
105
|
+
} else {
|
|
106
|
+
const readStdin = deps.readStdin ?? readAllStdin;
|
|
107
|
+
raw = await readStdin();
|
|
108
|
+
if (!raw.trim()) {
|
|
109
|
+
emitError("INVALID_ARGS", "No input provided. Pass a JSON argument or pipe JSON to stdin.");
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 2. Parse JSON.
|
|
115
|
+
let json: unknown;
|
|
116
|
+
try {
|
|
117
|
+
json = JSON.parse(raw);
|
|
118
|
+
} catch (err) {
|
|
119
|
+
emitError("INVALID_ARGS", `Invalid JSON: ${(err as Error).message}`);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 3. Validate against the shared Zod schema.
|
|
124
|
+
const result = schema.safeParse(json);
|
|
125
|
+
if (!result.success) {
|
|
126
|
+
emitError("INVALID_ARGS", "Input failed schema validation.", result.error.issues);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// 4. Build and emit. Classify failures: the typed SchemaRefreshError signal
|
|
131
|
+
// first, then a best-effort message regex (AUTH_FAILED / SCHEMA_PRIME_FAILED),
|
|
132
|
+
// else INTERNAL. The verbatim message is always preserved.
|
|
133
|
+
try {
|
|
134
|
+
const output = await build(result.data);
|
|
135
|
+
console.log(JSON.stringify(output));
|
|
136
|
+
} catch (err) {
|
|
137
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
138
|
+
// Typed signal wins; otherwise fall back to the best-effort message regex.
|
|
139
|
+
const code: MirrorErrorCode =
|
|
140
|
+
err instanceof SchemaRefreshError ? "SCHEMA_PRIME_FAILED" : classifyErrorMessage(message);
|
|
141
|
+
// The stack is omitted by default so the envelope never leaks internals;
|
|
142
|
+
// GRAPHITI_DEBUG=1 opts into attaching it under `details` for debugging.
|
|
143
|
+
const details =
|
|
144
|
+
process.env.GRAPHITI_DEBUG === "1" && err instanceof Error && err.stack
|
|
145
|
+
? { stack: err.stack }
|
|
146
|
+
: undefined;
|
|
147
|
+
emitError(code, message, details);
|
|
148
|
+
}
|
|
149
|
+
}
|
package/src/lib/graphql-name.ts
CHANGED
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
* warning (`buildOutput` never throws) while the malformed query is still
|
|
15
15
|
* returned as `ToolOutput.query`.
|
|
16
16
|
*
|
|
17
|
-
* The
|
|
18
|
-
* (`src/
|
|
17
|
+
* The schema boundary has its own zod-based `graphqlName()` factory
|
|
18
|
+
* (`src/schemas/fields.ts`); this is the intent-layer twin used
|
|
19
19
|
* by the builders, which are also reachable directly (CLI, eval harness).
|
|
20
20
|
*/
|
|
21
21
|
export const GRAPHQL_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
@@ -5,81 +5,15 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
8
|
-
import { z } from "zod";
|
|
9
|
-
import { graphqlName } from "./shared/zod-schemas.js";
|
|
10
8
|
import { buildAggregate } from "../../intent/build-aggregate.js";
|
|
11
|
-
import { GROUP_BY_FUNCTIONS } from "../../intent/types.js";
|
|
12
9
|
import { type PrimeDeps } from "../../lib/prime-schema.js";
|
|
10
|
+
import { AGGREGATE_INPUT } from "../../schemas/input-schemas.js";
|
|
13
11
|
|
|
14
12
|
export interface SfGqlAggregateToolOptions {
|
|
15
13
|
primeDeps?: PrimeDeps;
|
|
16
14
|
}
|
|
17
15
|
|
|
18
|
-
const
|
|
19
|
-
.string()
|
|
20
|
-
.optional()
|
|
21
|
-
.describe("GraphQL alias for this aggregation's result key.");
|
|
22
|
-
|
|
23
|
-
const aggregationSchema = z.discriminatedUnion("function", [
|
|
24
|
-
z.object({
|
|
25
|
-
function: z.enum(["count", "countDistinct"]),
|
|
26
|
-
field: z.string().optional().describe('SObject field API name. Defaults to "Id" when omitted.'),
|
|
27
|
-
alias: aliasField,
|
|
28
|
-
}),
|
|
29
|
-
z.object({
|
|
30
|
-
function: z.enum(["sum", "avg", "min", "max"]),
|
|
31
|
-
field: z.string().describe("SObject field API name. Required for sum/avg/min/max."),
|
|
32
|
-
alias: aliasField,
|
|
33
|
-
}),
|
|
34
|
-
]);
|
|
35
|
-
|
|
36
|
-
const groupByElementSchema = z.union([
|
|
37
|
-
z.string(),
|
|
38
|
-
z.object({
|
|
39
|
-
field: z.string().describe("SObject field API name (DateTime/Date field)."),
|
|
40
|
-
function: z
|
|
41
|
-
.enum(GROUP_BY_FUNCTIONS)
|
|
42
|
-
.describe("Date bucketing function from UIAPI GroupByFunction enum."),
|
|
43
|
-
}),
|
|
44
|
-
]);
|
|
45
|
-
|
|
46
|
-
const inputSchema = {
|
|
47
|
-
org: z.string().describe("Org alias resolved via local Salesforce CLI auth (~/.sf, ~/.sfdx)."),
|
|
48
|
-
object: graphqlName('SObject API name, e.g. "Account", "Order". Must be a valid GraphQL Name.'),
|
|
49
|
-
groupBy: z
|
|
50
|
-
.array(groupByElementSchema)
|
|
51
|
-
.optional()
|
|
52
|
-
.describe(
|
|
53
|
-
"GroupBy elements. Each is either a plain field name string (renders as `{Field: {group: true}}`) or an object `{field, function}` for date-bucketed groupBy (renders as `{Field: {function: CALENDAR_MONTH}}`). Omit or pass `[]` for un-grouped aggregation; with no aggregations either, defaults to `count` over `Id` (FR-8.2).",
|
|
54
|
-
),
|
|
55
|
-
aggregations: z
|
|
56
|
-
.array(aggregationSchema)
|
|
57
|
-
.optional()
|
|
58
|
-
.describe(
|
|
59
|
-
"List of aggregation functions to compute. Each entry projects under its alias (or default `<function><PascalCaseField>`).",
|
|
60
|
-
),
|
|
61
|
-
filter: z
|
|
62
|
-
.record(z.unknown())
|
|
63
|
-
.optional()
|
|
64
|
-
.describe(
|
|
65
|
-
"<Object>_Filter applied to every aggregation. String leaves matching $varName promote to typed query variables.",
|
|
66
|
-
),
|
|
67
|
-
orderBy: z
|
|
68
|
-
.union([z.record(z.unknown()), z.array(z.record(z.unknown()))])
|
|
69
|
-
.optional()
|
|
70
|
-
.describe(
|
|
71
|
-
"<Object>_OrderBy. Singleton object preferred; arrays are collapsed to the first entry. String leaves matching $varName promote to typed query variables.",
|
|
72
|
-
),
|
|
73
|
-
first: z
|
|
74
|
-
.number()
|
|
75
|
-
.int()
|
|
76
|
-
.positive()
|
|
77
|
-
.optional()
|
|
78
|
-
.describe("Connection page size (top-N pattern). No default — omit for all buckets."),
|
|
79
|
-
operationName: graphqlName(
|
|
80
|
-
"Override the GraphQL operation name. Defaults to <Object>Aggregate.",
|
|
81
|
-
).optional(),
|
|
82
|
-
};
|
|
16
|
+
const inputSchema = AGGREGATE_INPUT.shape;
|
|
83
17
|
|
|
84
18
|
export function registerSfGqlAggregateTool(
|
|
85
19
|
server: McpServer,
|
|
@@ -5,21 +5,12 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
8
|
-
import { z } from "zod";
|
|
9
|
-
import { orgAlias } from "./shared/zod-schemas.js";
|
|
10
8
|
import { buildConnect, type ConnectDeps } from "../../intent/build-connect.js";
|
|
9
|
+
import { CONNECT_INPUT } from "../../schemas/input-schemas.js";
|
|
11
10
|
|
|
12
11
|
export type SfGqlConnectToolOptions = ConnectDeps;
|
|
13
12
|
|
|
14
|
-
const inputSchema =
|
|
15
|
-
org: orgAlias("Org alias or username resolved via local Salesforce CLI auth (~/.sf, ~/.sfdx)."),
|
|
16
|
-
forceRefresh: z
|
|
17
|
-
.boolean()
|
|
18
|
-
.optional()
|
|
19
|
-
.describe(
|
|
20
|
-
"Re-download the schema even if it is already cached, clearing the on-disk introspection JSON, the in-memory parsed schema, and the ObjectInfo cache. Use after deploying new metadata (fields, picklist values, objects) so subsequent tools see the changes. Defaults to false.",
|
|
21
|
-
),
|
|
22
|
-
};
|
|
13
|
+
const inputSchema = CONNECT_INPUT.shape;
|
|
23
14
|
|
|
24
15
|
export function registerSfGqlConnectTool(
|
|
25
16
|
server: McpServer,
|
|
@@ -5,34 +5,15 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
8
|
-
import { z } from "zod";
|
|
9
|
-
import { graphqlName } from "./shared/zod-schemas.js";
|
|
10
8
|
import { buildCreate } from "../../intent/build-create.js";
|
|
11
9
|
import { type PrimeDeps } from "../../lib/prime-schema.js";
|
|
10
|
+
import { CREATE_INPUT } from "../../schemas/input-schemas.js";
|
|
12
11
|
|
|
13
12
|
export interface SfGqlCreateToolOptions {
|
|
14
13
|
primeDeps?: PrimeDeps;
|
|
15
14
|
}
|
|
16
15
|
|
|
17
|
-
const inputSchema =
|
|
18
|
-
org: z.string().describe("Org alias resolved via local Salesforce CLI auth (~/.sf, ~/.sfdx)."),
|
|
19
|
-
object: graphqlName('SObject API name, e.g. "Account", "Order". Must be a valid GraphQL Name.'),
|
|
20
|
-
returnFields: z
|
|
21
|
-
.array(z.string())
|
|
22
|
-
.optional()
|
|
23
|
-
.describe(
|
|
24
|
-
'Scalar field API names to read back on the created Record. Defaults to ["Id"]. Dot-paths like Owner.Name are not supported in mutation results; passing them produces a warning and the field is skipped.',
|
|
25
|
-
),
|
|
26
|
-
inputVariable: z
|
|
27
|
-
.string()
|
|
28
|
-
.optional()
|
|
29
|
-
.describe(
|
|
30
|
-
'Variable name (without "$") for the create input. Defaults to "input". Declared as $<name>: <Object>CreateInput!.',
|
|
31
|
-
),
|
|
32
|
-
operationName: graphqlName(
|
|
33
|
-
"Override the GraphQL operation name. Defaults to Create<Object>.",
|
|
34
|
-
).optional(),
|
|
35
|
-
};
|
|
16
|
+
const inputSchema = CREATE_INPUT.shape;
|
|
36
17
|
|
|
37
18
|
export function registerSfGqlCreateTool(
|
|
38
19
|
server: McpServer,
|
|
@@ -5,28 +5,15 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
8
|
-
import { z } from "zod";
|
|
9
|
-
import { graphqlName } from "./shared/zod-schemas.js";
|
|
10
8
|
import { buildDelete } from "../../intent/build-delete.js";
|
|
11
9
|
import { type PrimeDeps } from "../../lib/prime-schema.js";
|
|
10
|
+
import { DELETE_INPUT } from "../../schemas/input-schemas.js";
|
|
12
11
|
|
|
13
12
|
export interface SfGqlDeleteToolOptions {
|
|
14
13
|
primeDeps?: PrimeDeps;
|
|
15
14
|
}
|
|
16
15
|
|
|
17
|
-
const inputSchema =
|
|
18
|
-
org: z.string().describe("Org alias resolved via local Salesforce CLI auth (~/.sf, ~/.sfdx)."),
|
|
19
|
-
object: graphqlName('SObject API name, e.g. "Account", "Order". Must be a valid GraphQL Name.'),
|
|
20
|
-
inputVariable: z
|
|
21
|
-
.string()
|
|
22
|
-
.optional()
|
|
23
|
-
.describe(
|
|
24
|
-
'Variable name (without "$") for the delete input. Defaults to "input". Declared as $<name>: RecordDeleteInput! — the schema-wide delete input carrying the record Id, not an <Object>-specific type.',
|
|
25
|
-
),
|
|
26
|
-
operationName: graphqlName(
|
|
27
|
-
"Override the GraphQL operation name. Defaults to Delete<Object>.",
|
|
28
|
-
).optional(),
|
|
29
|
-
};
|
|
16
|
+
const inputSchema = DELETE_INPUT.shape;
|
|
30
17
|
|
|
31
18
|
export function registerSfGqlDeleteTool(
|
|
32
19
|
server: McpServer,
|