@salesforce/b2c-dx-mcp 3.0.1 → 3.1.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/content/guidance/b2c-cli/b2c-config/SKILL.md +18 -1
- package/content/guidance/b2c-cli/b2c-job/SKILL.md +2 -0
- package/content/guidance/b2c-cli/b2c-job/references/EXPORT.md +9 -3
- package/content/guidance/b2c-cli/b2c-job/references/IMPORT.md +12 -0
- package/content/guidance/b2c-cli/b2c-mrt/SKILL.md +5 -4
- package/content/guidance/b2c-cli/b2c-mrt/references/BUNDLE-COMMANDS.md +21 -3
- package/content/guidance/b2c-cli/b2c-mrt/references/ENVIRONMENT-COMMANDS.md +23 -0
- package/content/guidance/b2c-cli/b2c-webdav/SKILL.md +17 -0
- package/content/guidance/index.json +18 -18
- package/content/guidance/mcp/scapi/SKILL.md +46 -14
- package/dist/guidance.js +0 -2
- package/dist/registry.js +1 -1
- package/dist/server-context.d.ts +2 -0
- package/dist/server-context.js +4 -0
- package/dist/server.d.ts +2 -4
- package/dist/server.js +16 -3
- package/dist/tools/scapi/execution-registry.d.ts +62 -0
- package/dist/tools/scapi/execution-registry.js +303 -0
- package/dist/tools/scapi/index.d.ts +2 -1
- package/dist/tools/scapi/index.js +2 -2
- package/dist/tools/scapi/scapi-code.d.ts +2 -1
- package/dist/tools/scapi/scapi-code.js +71 -34
- package/dist/utils/types.d.ts +10 -3
- package/node_modules/@modelcontextprotocol/server/dist/createMcpHandler-CLhGwQTn.d.mts +2 -1
- package/node_modules/@modelcontextprotocol/server/dist/createMcpHandler-dBHMsxwf.d.cts +2 -1
- package/node_modules/@modelcontextprotocol/server/dist/mcp-D7GmuPnv.cjs +1 -1
- package/node_modules/@modelcontextprotocol/server/dist/mcp-DXXb3Vv3.mjs +1 -1
- package/node_modules/@modelcontextprotocol/server/dist/src-CX2iR2pK.mjs +2 -0
- package/node_modules/@modelcontextprotocol/server/dist/src-STyD_Vvf.cjs +2 -0
- package/node_modules/@salesforce/b2c-agent-plugins/CHANGELOG.md +22 -0
- package/node_modules/@salesforce/b2c-agent-plugins/README.md +4 -2
- package/node_modules/@salesforce/b2c-agent-plugins/b2c/.codex-plugin/plugin.json +1 -1
- package/node_modules/@salesforce/b2c-agent-plugins/b2c/plugin.json +1 -1
- package/node_modules/@salesforce/b2c-agent-plugins/b2c-cli/.codex-plugin/plugin.json +1 -1
- package/node_modules/@salesforce/b2c-agent-plugins/b2c-cli/plugin.json +1 -1
- package/node_modules/@salesforce/b2c-agent-plugins/b2c-cli/skills/b2c-config/SKILL.md +18 -1
- package/node_modules/@salesforce/b2c-agent-plugins/b2c-cli/skills/b2c-job/SKILL.md +2 -0
- package/node_modules/@salesforce/b2c-agent-plugins/b2c-cli/skills/b2c-job/references/EXPORT.md +9 -3
- package/node_modules/@salesforce/b2c-agent-plugins/b2c-cli/skills/b2c-job/references/IMPORT.md +12 -0
- package/node_modules/@salesforce/b2c-agent-plugins/b2c-cli/skills/b2c-mrt/SKILL.md +5 -4
- package/node_modules/@salesforce/b2c-agent-plugins/b2c-cli/skills/b2c-mrt/references/BUNDLE-COMMANDS.md +21 -3
- package/node_modules/@salesforce/b2c-agent-plugins/b2c-cli/skills/b2c-mrt/references/ENVIRONMENT-COMMANDS.md +23 -0
- package/node_modules/@salesforce/b2c-agent-plugins/b2c-cli/skills/b2c-webdav/SKILL.md +17 -0
- package/node_modules/@salesforce/b2c-agent-plugins/b2c-ops/.codex-plugin/plugin.json +1 -1
- package/node_modules/@salesforce/b2c-agent-plugins/b2c-ops/plugin.json +1 -1
- package/node_modules/@salesforce/b2c-agent-plugins/b2c-python-sdk/.codex-plugin/plugin.json +41 -0
- package/node_modules/@salesforce/b2c-agent-plugins/b2c-python-sdk/README.md +35 -0
- package/node_modules/@salesforce/b2c-agent-plugins/b2c-python-sdk/assets/logo.svg +31 -0
- package/node_modules/@salesforce/b2c-agent-plugins/b2c-python-sdk/plugin.json +45 -0
- package/node_modules/@salesforce/b2c-agent-plugins/b2c-python-sdk/skills/using-b2c-tooling-sdk/SKILL.md +226 -0
- package/node_modules/@salesforce/b2c-agent-plugins/b2c-python-sdk/skills/using-b2c-tooling-sdk/references/api-catalog.md +154 -0
- package/node_modules/@salesforce/b2c-agent-plugins/package.json +1 -1
- package/node_modules/@salesforce/b2c-agent-plugins/plugins.json +3 -0
- package/node_modules/@salesforce/b2c-agent-plugins/storefront-next/.codex-plugin/plugin.json +1 -1
- package/node_modules/@salesforce/b2c-agent-plugins/storefront-next/plugin.json +1 -1
- package/oclif.manifest.json +1 -1
- package/package.json +3 -3
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { ScapiConfirmation, ScapiRuntimeControl } from '@salesforce/b2c-tooling-sdk/scapi';
|
|
2
|
+
import type { ToolContext, ToolResult } from '../../utils/types.js';
|
|
3
|
+
declare function deferred<T>(): {
|
|
4
|
+
promise: Promise<T>;
|
|
5
|
+
resolve: (value: T) => void;
|
|
6
|
+
reject: (reason: Error) => void;
|
|
7
|
+
};
|
|
8
|
+
interface OperationRecord {
|
|
9
|
+
kind: 'auth' | 'request';
|
|
10
|
+
method?: string;
|
|
11
|
+
path?: string;
|
|
12
|
+
status: 'awaiting_confirmation' | 'completed' | 'failed' | 'in_flight' | 'not_sent' | 'unknown';
|
|
13
|
+
httpStatus?: number;
|
|
14
|
+
}
|
|
15
|
+
interface ApprovalRound {
|
|
16
|
+
state: string;
|
|
17
|
+
key: string;
|
|
18
|
+
prompt: ToolResult;
|
|
19
|
+
decision: ReturnType<typeof deferred<void>>;
|
|
20
|
+
responded?: boolean;
|
|
21
|
+
}
|
|
22
|
+
/** One worker survives protocol round trips; none of its code is replayed. */
|
|
23
|
+
export declare class ScapiExecution {
|
|
24
|
+
readonly digest: string;
|
|
25
|
+
private readonly supportsElicitation;
|
|
26
|
+
private readonly mintState;
|
|
27
|
+
control?: ScapiRuntimeControl;
|
|
28
|
+
readonly controller: AbortController;
|
|
29
|
+
done: Promise<void>;
|
|
30
|
+
readonly id: `${string}-${string}-${string}-${string}-${string}`;
|
|
31
|
+
readonly operations: OperationRecord[];
|
|
32
|
+
result?: ToolResult;
|
|
33
|
+
readonly rounds: Map<string, ApprovalRound>;
|
|
34
|
+
private cancellation?;
|
|
35
|
+
private cancellationListeners;
|
|
36
|
+
private dispatched;
|
|
37
|
+
private event;
|
|
38
|
+
private pending?;
|
|
39
|
+
private queue;
|
|
40
|
+
constructor(digest: string, supportsElicitation: boolean, mintState: (round: number) => string);
|
|
41
|
+
cancel(reason?: string): void;
|
|
42
|
+
confirm(request: ScapiConfirmation): Promise<void>;
|
|
43
|
+
finish(result: ToolResult): void;
|
|
44
|
+
markDispatched(): void;
|
|
45
|
+
retry(state: string, context: ToolContext): Promise<ToolResult>;
|
|
46
|
+
runCall<T>(kind: OperationRecord['kind'], input: unknown, operation: () => Promise<T>): Promise<T>;
|
|
47
|
+
wait(context?: ToolContext): Promise<ToolResult>;
|
|
48
|
+
}
|
|
49
|
+
/** Stdio-server-owned continuations. Terminal records are bounded; tokens never start new work. */
|
|
50
|
+
export declare class ScapiExecutionRegistry {
|
|
51
|
+
private readonly options;
|
|
52
|
+
private readonly executions;
|
|
53
|
+
private readonly secret;
|
|
54
|
+
constructor(options?: {
|
|
55
|
+
maxActive?: number;
|
|
56
|
+
});
|
|
57
|
+
cancel(id: string): Promise<ToolResult>;
|
|
58
|
+
destroyAll(): Promise<void>;
|
|
59
|
+
retry(args: unknown, context: ToolContext): Promise<ToolResult>;
|
|
60
|
+
start(args: unknown, context: ToolContext | undefined, run: (execution: ScapiExecution) => Promise<ToolResult>): Promise<ToolResult>;
|
|
61
|
+
}
|
|
62
|
+
export {};
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2025, Salesforce, Inc.
|
|
3
|
+
* SPDX-License-Identifier: Apache-2
|
|
4
|
+
* For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0
|
|
5
|
+
*/
|
|
6
|
+
import { createHash, createHmac, randomBytes, randomUUID } from 'node:crypto';
|
|
7
|
+
import { inputRequired } from '@modelcontextprotocol/server';
|
|
8
|
+
function deferred() {
|
|
9
|
+
let resolve;
|
|
10
|
+
let reject;
|
|
11
|
+
const promise = new Promise((yes, no) => {
|
|
12
|
+
resolve = yes;
|
|
13
|
+
reject = no;
|
|
14
|
+
});
|
|
15
|
+
return { promise, resolve, reject };
|
|
16
|
+
}
|
|
17
|
+
function fingerprint(args) {
|
|
18
|
+
const canonical = (value) => {
|
|
19
|
+
if (Array.isArray(value))
|
|
20
|
+
return value.map((item) => canonical(item));
|
|
21
|
+
if (value && typeof value === 'object')
|
|
22
|
+
return Object.fromEntries(Object.entries(value)
|
|
23
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
24
|
+
.map(([k, v]) => [k, canonical(v)]));
|
|
25
|
+
return value;
|
|
26
|
+
};
|
|
27
|
+
return createHash('sha256')
|
|
28
|
+
.update(JSON.stringify(canonical(args)))
|
|
29
|
+
.digest('hex');
|
|
30
|
+
}
|
|
31
|
+
function errorResult(message) {
|
|
32
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: message }) }] };
|
|
33
|
+
}
|
|
34
|
+
function preview(value) {
|
|
35
|
+
const json = JSON.stringify(value, (key, item) => (/password|secret|token|authorization|credential/i.test(key) ? '[redacted]' : item), 2) ?? 'none';
|
|
36
|
+
const excerpt = json.split('\n').slice(0, 5).join('\n');
|
|
37
|
+
if (excerpt.length === json.length && json.length <= 400)
|
|
38
|
+
return json;
|
|
39
|
+
const notice = ' ... [truncated; approval covers full request]';
|
|
40
|
+
return excerpt.slice(0, 400 - notice.length).trimEnd() + notice;
|
|
41
|
+
}
|
|
42
|
+
/** One worker survives protocol round trips; none of its code is replayed. */
|
|
43
|
+
export class ScapiExecution {
|
|
44
|
+
digest;
|
|
45
|
+
supportsElicitation;
|
|
46
|
+
mintState;
|
|
47
|
+
control;
|
|
48
|
+
controller = new AbortController();
|
|
49
|
+
done;
|
|
50
|
+
id = randomUUID();
|
|
51
|
+
operations = [];
|
|
52
|
+
result;
|
|
53
|
+
rounds = new Map();
|
|
54
|
+
cancellation;
|
|
55
|
+
cancellationListeners = new Map();
|
|
56
|
+
dispatched = new WeakSet();
|
|
57
|
+
event = deferred();
|
|
58
|
+
pending;
|
|
59
|
+
queue = Promise.resolve();
|
|
60
|
+
constructor(digest, supportsElicitation, mintState) {
|
|
61
|
+
this.digest = digest;
|
|
62
|
+
this.supportsElicitation = supportsElicitation;
|
|
63
|
+
this.mintState = mintState;
|
|
64
|
+
}
|
|
65
|
+
cancel(reason = 'SCAPI_EXECUTION_CANCELLED: explicitly cancelled.') {
|
|
66
|
+
if (this.result || this.cancellation)
|
|
67
|
+
return;
|
|
68
|
+
this.cancellation = reason;
|
|
69
|
+
const error = new Error(reason);
|
|
70
|
+
this.controller.abort(error);
|
|
71
|
+
this.pending?.decision.reject(error);
|
|
72
|
+
}
|
|
73
|
+
async confirm(request) {
|
|
74
|
+
this.controller.signal.throwIfAborted();
|
|
75
|
+
const record = this.operations.at(-1);
|
|
76
|
+
if (record)
|
|
77
|
+
record.status = 'awaiting_confirmation';
|
|
78
|
+
if (!this.supportsElicitation) {
|
|
79
|
+
this.cancel('SCAPI_CONFIRMATION_UNSUPPORTED: Confirmation required; this client does not support form elicitation.');
|
|
80
|
+
this.controller.signal.throwIfAborted();
|
|
81
|
+
}
|
|
82
|
+
const roundNumber = this.rounds.size + 1;
|
|
83
|
+
const state = this.mintState(roundNumber);
|
|
84
|
+
const key = `approve_${roundNumber}`;
|
|
85
|
+
const path = new URL(request.url).pathname;
|
|
86
|
+
const organizationId = path.match(/\/organizations\/([^/]+)/)?.[1];
|
|
87
|
+
const earlierWrites = this.operations.filter((item) => item.kind === 'request' && !['GET', 'HEAD', 'OPTIONS'].includes(item.method ?? '') && this.dispatched.has(item)).length;
|
|
88
|
+
const message = [
|
|
89
|
+
`Approve SCAPI operation: ${request.operationId}`,
|
|
90
|
+
...(organizationId ? [`Organization: ${organizationId}`] : []),
|
|
91
|
+
`${request.method} ${path}`,
|
|
92
|
+
...(Object.keys(request.query).length > 0 ? [`Query:\n${preview(request.query)}`] : []),
|
|
93
|
+
...(request.body === undefined ? [] : [`Body:\n${preview(request.body)}`]),
|
|
94
|
+
'Safety Mode requires approval. Declining stops this execution.',
|
|
95
|
+
earlierWrites ? `Earlier write requests sent: ${earlierWrites}; no rollback.` : 'No earlier writes.',
|
|
96
|
+
`Execution: ${this.id}`,
|
|
97
|
+
].join('\n');
|
|
98
|
+
const prompt = {
|
|
99
|
+
...inputRequired({
|
|
100
|
+
requestState: state,
|
|
101
|
+
inputRequests: {
|
|
102
|
+
[key]: inputRequired.elicit({
|
|
103
|
+
message,
|
|
104
|
+
requestedSchema: {
|
|
105
|
+
type: 'object',
|
|
106
|
+
properties: { approve: { type: 'boolean', title: 'Approve this request only', default: false } },
|
|
107
|
+
required: ['approve'],
|
|
108
|
+
},
|
|
109
|
+
}),
|
|
110
|
+
},
|
|
111
|
+
}),
|
|
112
|
+
// Keep the local tool result shape; the SDK dispatches by resultType before content.
|
|
113
|
+
content: [],
|
|
114
|
+
_meta: {
|
|
115
|
+
executionId: this.id,
|
|
116
|
+
status: 'awaiting_confirmation',
|
|
117
|
+
operations: structuredClone(this.operations),
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
const round = { state, key, prompt, decision: deferred() };
|
|
121
|
+
this.pending = round;
|
|
122
|
+
this.rounds.set(state, round);
|
|
123
|
+
this.control?.pauseTimeout();
|
|
124
|
+
this.event.resolve(prompt);
|
|
125
|
+
try {
|
|
126
|
+
await round.decision.promise;
|
|
127
|
+
this.controller.signal.throwIfAborted();
|
|
128
|
+
if (record)
|
|
129
|
+
record.status = 'in_flight';
|
|
130
|
+
}
|
|
131
|
+
finally {
|
|
132
|
+
this.pending = undefined;
|
|
133
|
+
this.control?.resumeTimeout();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
finish(result) {
|
|
137
|
+
this.controller.abort(new Error('SCAPI_EXECUTION_FINISHED'));
|
|
138
|
+
for (const [signal, listener] of this.cancellationListeners)
|
|
139
|
+
signal.removeEventListener('abort', listener);
|
|
140
|
+
this.cancellationListeners.clear();
|
|
141
|
+
this.pending?.decision.reject(new Error('SCAPI_EXECUTION_FINISHED'));
|
|
142
|
+
for (const record of this.operations) {
|
|
143
|
+
if (record.status === 'awaiting_confirmation')
|
|
144
|
+
record.status = 'not_sent';
|
|
145
|
+
else if (record.status === 'in_flight')
|
|
146
|
+
record.status = this.dispatched.has(record) ? 'unknown' : 'not_sent';
|
|
147
|
+
}
|
|
148
|
+
const output = this.cancellation ? { ...result, isError: true } : result;
|
|
149
|
+
const first = output.content[0];
|
|
150
|
+
const data = first?.type === 'text' ? JSON.parse(first.text) : {};
|
|
151
|
+
if (this.cancellation) {
|
|
152
|
+
delete data.result;
|
|
153
|
+
data.error = this.cancellation + ' Check earlier writes before retrying; no rollback is performed.';
|
|
154
|
+
}
|
|
155
|
+
this.result = {
|
|
156
|
+
...output,
|
|
157
|
+
content: [
|
|
158
|
+
{
|
|
159
|
+
type: 'text',
|
|
160
|
+
text: JSON.stringify({
|
|
161
|
+
...data,
|
|
162
|
+
executionId: this.id,
|
|
163
|
+
status: this.cancellation ? 'cancelled' : output.isError ? 'failed' : 'completed',
|
|
164
|
+
operations: this.operations,
|
|
165
|
+
}),
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
};
|
|
169
|
+
this.event.resolve(this.result);
|
|
170
|
+
}
|
|
171
|
+
markDispatched() {
|
|
172
|
+
const record = this.operations.at(-1);
|
|
173
|
+
if (record)
|
|
174
|
+
this.dispatched.add(record);
|
|
175
|
+
}
|
|
176
|
+
async retry(state, context) {
|
|
177
|
+
const round = this.rounds.get(state);
|
|
178
|
+
if (!round)
|
|
179
|
+
throw new Error('SCAPI_CONTINUATION_INVALID: unknown or altered continuation.');
|
|
180
|
+
if (this.cancellation)
|
|
181
|
+
await this.done;
|
|
182
|
+
if (this.result)
|
|
183
|
+
return this.result;
|
|
184
|
+
if (round.responded)
|
|
185
|
+
return this.wait(context);
|
|
186
|
+
if (round !== this.pending)
|
|
187
|
+
throw new Error('SCAPI_CONTINUATION_STALE: approval round is no longer pending.');
|
|
188
|
+
const response = context.inputResponses?.[round.key];
|
|
189
|
+
if (!response)
|
|
190
|
+
return this.wait(context);
|
|
191
|
+
this.event = deferred();
|
|
192
|
+
round.responded = true;
|
|
193
|
+
const result = this.wait(context);
|
|
194
|
+
if (!context.supportsElicitation || response.action !== 'accept' || response.content?.approve !== true) {
|
|
195
|
+
this.cancel('SCAPI_APPROVAL_DECLINED: execution cancelled without sending the pending request.');
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
round.decision.resolve();
|
|
199
|
+
}
|
|
200
|
+
return result;
|
|
201
|
+
}
|
|
202
|
+
async runCall(kind, input, operation) {
|
|
203
|
+
const previous = this.queue;
|
|
204
|
+
const call = previous.then(async () => {
|
|
205
|
+
this.controller.signal.throwIfAborted();
|
|
206
|
+
const args = input;
|
|
207
|
+
const record = {
|
|
208
|
+
kind,
|
|
209
|
+
...(typeof args?.method === 'string' ? { method: args.method.toUpperCase() } : {}),
|
|
210
|
+
...(typeof args?.path === 'string' ? { path: args.path.slice(0, 500) } : {}),
|
|
211
|
+
status: 'in_flight',
|
|
212
|
+
};
|
|
213
|
+
this.operations.push(record);
|
|
214
|
+
try {
|
|
215
|
+
const result = await operation();
|
|
216
|
+
record.status = 'completed';
|
|
217
|
+
if (result && typeof result === 'object' && 'status' in result && typeof result.status === 'number')
|
|
218
|
+
record.httpStatus = result.status;
|
|
219
|
+
return result;
|
|
220
|
+
}
|
|
221
|
+
catch (error) {
|
|
222
|
+
record.status = kind === 'auth' ? 'failed' : this.dispatched.has(record) ? 'unknown' : 'not_sent';
|
|
223
|
+
throw error;
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
this.queue = call.catch(() => { });
|
|
227
|
+
return call;
|
|
228
|
+
}
|
|
229
|
+
async wait(context) {
|
|
230
|
+
const signal = context?.signal;
|
|
231
|
+
if (signal && !this.cancellationListeners.has(signal)) {
|
|
232
|
+
const cancel = () => this.cancel('SCAPI_EXECUTION_CANCELLED: client cancelled the request.');
|
|
233
|
+
this.cancellationListeners.set(signal, cancel);
|
|
234
|
+
signal.addEventListener('abort', cancel, { once: true });
|
|
235
|
+
if (signal.aborted)
|
|
236
|
+
cancel();
|
|
237
|
+
}
|
|
238
|
+
try {
|
|
239
|
+
return this.result ?? (await this.event.promise);
|
|
240
|
+
}
|
|
241
|
+
finally {
|
|
242
|
+
if (signal && (!context?.keepCancellation || this.result)) {
|
|
243
|
+
signal.removeEventListener('abort', this.cancellationListeners.get(signal));
|
|
244
|
+
this.cancellationListeners.delete(signal);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
/** Stdio-server-owned continuations. Terminal records are bounded; tokens never start new work. */
|
|
250
|
+
export class ScapiExecutionRegistry {
|
|
251
|
+
options;
|
|
252
|
+
executions = new Map();
|
|
253
|
+
secret = randomBytes(32);
|
|
254
|
+
constructor(options = {}) {
|
|
255
|
+
this.options = options;
|
|
256
|
+
}
|
|
257
|
+
async cancel(id) {
|
|
258
|
+
const execution = this.executions.get(id);
|
|
259
|
+
if (!execution)
|
|
260
|
+
throw new Error('SCAPI_EXECUTION_NOT_FOUND: execution is unavailable in this server session.');
|
|
261
|
+
execution.cancel();
|
|
262
|
+
await execution.done;
|
|
263
|
+
return execution.result;
|
|
264
|
+
}
|
|
265
|
+
async destroyAll() {
|
|
266
|
+
for (const execution of this.executions.values())
|
|
267
|
+
execution.cancel('SCAPI_EXECUTION_CANCELLED: server shutting down.');
|
|
268
|
+
await Promise.allSettled([...this.executions.values()].map((execution) => execution.done));
|
|
269
|
+
this.executions.clear();
|
|
270
|
+
}
|
|
271
|
+
async retry(args, context) {
|
|
272
|
+
const state = context.requestState;
|
|
273
|
+
if (typeof state !== 'string')
|
|
274
|
+
throw new Error('SCAPI_CONTINUATION_INVALID: expected server-issued requestState.');
|
|
275
|
+
const execution = this.executions.get(state.split('.')[0]);
|
|
276
|
+
// Exact server-side lookup validates the MAC and binds state to this server, round and original arguments.
|
|
277
|
+
if (!execution || !execution.rounds.has(state))
|
|
278
|
+
throw new Error('SCAPI_CONTINUATION_INVALID: execution unavailable, server restarted, or state was altered.');
|
|
279
|
+
if (execution.digest !== fingerprint(args))
|
|
280
|
+
throw new Error('SCAPI_CONTINUATION_MISMATCH: original code, input and target arguments must be unchanged. Start new code with a fresh call.');
|
|
281
|
+
return execution.retry(state, context);
|
|
282
|
+
}
|
|
283
|
+
async start(args, context, run) {
|
|
284
|
+
if ([...this.executions.values()].filter((execution) => !execution.result).length >= (this.options.maxActive ?? 4))
|
|
285
|
+
throw new Error('SCAPI_EXECUTION_LIMIT: cancel an existing execution or wait for it to finish.');
|
|
286
|
+
const digest = fingerprint(args);
|
|
287
|
+
const execution = new ScapiExecution(digest, context?.supportsElicitation === true, (round) => {
|
|
288
|
+
const prefix = `${execution.id}.${round}`;
|
|
289
|
+
return `${prefix}.${createHmac('sha256', this.secret).update(`${prefix}.${digest}`).digest('base64url')}`;
|
|
290
|
+
});
|
|
291
|
+
this.executions.set(execution.id, execution);
|
|
292
|
+
execution.done = Promise.resolve()
|
|
293
|
+
.then(() => run(execution))
|
|
294
|
+
.then((result) => execution.finish(result), (error) => execution.finish(errorResult(error instanceof Error ? error.message : String(error))))
|
|
295
|
+
.then(() => {
|
|
296
|
+
const terminal = [...this.executions.values()].filter((item) => item.result);
|
|
297
|
+
for (const item of terminal.slice(0, Math.max(0, terminal.length - 50)))
|
|
298
|
+
this.executions.delete(item.id);
|
|
299
|
+
});
|
|
300
|
+
return execution.wait(context);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
//# sourceMappingURL=execution-registry.js.map
|
|
@@ -8,10 +8,11 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import type { McpTool } from '../../utils/index.js';
|
|
10
10
|
import type { Services } from '../../services.js';
|
|
11
|
+
import type { ScapiExecutionRegistry } from './execution-registry.js';
|
|
11
12
|
/**
|
|
12
13
|
* Creates all tools for the SCAPI toolset.
|
|
13
14
|
*
|
|
14
15
|
* @param loadServices - Function that loads configuration and returns Services instance
|
|
15
16
|
* @returns Array of MCP tools
|
|
16
17
|
*/
|
|
17
|
-
export declare function createScapiTools(loadServices: () => Promise<Services> | Services): McpTool[];
|
|
18
|
+
export declare function createScapiTools(loadServices: () => Promise<Services> | Services, executions?: ScapiExecutionRegistry): McpTool[];
|
|
@@ -13,9 +13,9 @@ import { createScapiCodeTools } from './scapi-code.js';
|
|
|
13
13
|
* @param loadServices - Function that loads configuration and returns Services instance
|
|
14
14
|
* @returns Array of MCP tools
|
|
15
15
|
*/
|
|
16
|
-
export function createScapiTools(loadServices) {
|
|
16
|
+
export function createScapiTools(loadServices, executions) {
|
|
17
17
|
return [
|
|
18
|
-
...createScapiCodeTools(loadServices),
|
|
18
|
+
...createScapiCodeTools(loadServices, undefined, executions),
|
|
19
19
|
createMetricsGetTool(loadServices),
|
|
20
20
|
createScapiCustomApisStatusTool(loadServices),
|
|
21
21
|
createScapiSchemasListTool(loadServices),
|
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
import type { McpTool } from '../../utils/types.js';
|
|
2
2
|
import type { ServicesLoader } from '../adapter.js';
|
|
3
|
-
|
|
3
|
+
import { ScapiExecutionRegistry } from './execution-registry.js';
|
|
4
|
+
export declare function createScapiCodeTools(loadServices: ServicesLoader, snippetDirectory?: string, registry?: ScapiExecutionRegistry): McpTool[];
|
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
* For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0
|
|
5
5
|
*/
|
|
6
6
|
import { z } from 'zod';
|
|
7
|
-
import { randomUUID } from 'node:crypto';
|
|
8
7
|
import { loadScapiSchemas, runScapiCode, createScapiRequest, createScapiAuth, loadScapiSnippets, saveScapiSnippet, } from '@salesforce/b2c-tooling-sdk/scapi';
|
|
9
8
|
import { toOrganizationId } from '@salesforce/b2c-tooling-sdk/clients';
|
|
10
9
|
import { getB2CConfigDirectory } from '@salesforce/b2c-tooling-sdk/config';
|
|
@@ -12,6 +11,7 @@ import { resolveEffectiveSafetyConfig, loadGlobalSafetyConfig } from '@salesforc
|
|
|
12
11
|
import { createProjectContextInputSchema } from '../project-context.js';
|
|
13
12
|
import { jsonResult, attachResolution } from '../adapter.js';
|
|
14
13
|
import { MCP_SKILL_REFERENCES } from '../../skill-references.js';
|
|
14
|
+
import { ScapiExecutionRegistry } from './execution-registry.js';
|
|
15
15
|
const code = z
|
|
16
16
|
.string()
|
|
17
17
|
.min(1)
|
|
@@ -60,7 +60,8 @@ async () => {
|
|
|
60
60
|
const executeDescription = `Read, create, update, or delete Commerce records through SCAPI Admin APIs when no dedicated tool fits. Discover standard endpoints with scapi_search; for custom APIs, fetch the live contract through scapi.request() in the same program first. Requests authenticate automatically. JSON requests; no Shopper execution or binary transfers.
|
|
61
61
|
Read skill://mcp/scapi/SKILL.md first.
|
|
62
62
|
|
|
63
|
-
Reuse workflows with await codemode.run(name, input); describe
|
|
63
|
+
Reuse workflows with await codemode.run(name, input); describe first. async (input) receives input. Completed executionId enables scapi_snippet_save.
|
|
64
|
+
Safety confirmation uses MCP elicitation. Protocol retries resume retained code; never replay it. Approval has no server deadline. Decline or cancel terminates execution without rollback. action:cancel needs executionId only (plus skillRead).
|
|
64
65
|
|
|
65
66
|
Available in your code:
|
|
66
67
|
declare const organizationId: string | undefined; // resolved tenant
|
|
@@ -70,7 +71,7 @@ declare const scapi: {
|
|
|
70
71
|
Promise<{status: number; ok: boolean; data: any; diagnostic?: {code: string; message: string}}>;
|
|
71
72
|
};
|
|
72
73
|
|
|
73
|
-
Compose
|
|
74
|
+
Compose dependent requests; await each. Responses can be huge: filter/map/slice; return counts, selected rows and verification. Preserve failures. HTTP failures return ok:false; transport/auth/safety failures throw. SDK safety governs scapi.request. fetch/WebSocket are disabled. Optional auth.accountManager()/auth.slas() export tokens for external clients; see skill. Check writes before retrying.
|
|
74
75
|
|
|
75
76
|
Example: inspect a campaign's promotions
|
|
76
77
|
async () => codemode.run('builtin/campaign-promotions', {campaignId: 'selected-campaign', limit: 4})`;
|
|
@@ -92,7 +93,7 @@ function failure(error, resolution) {
|
|
|
92
93
|
isError: true,
|
|
93
94
|
};
|
|
94
95
|
}
|
|
95
|
-
export function createScapiCodeTools(loadServices, snippetDirectory) {
|
|
96
|
+
export function createScapiCodeTools(loadServices, snippetDirectory, registry = new ScapiExecutionRegistry()) {
|
|
96
97
|
// Retain only source for the last 50 completed executions, until this server ends.
|
|
97
98
|
const executions = new Map();
|
|
98
99
|
const searchInput = {
|
|
@@ -106,7 +107,16 @@ export function createScapiCodeTools(loadServices, snippetDirectory) {
|
|
|
106
107
|
};
|
|
107
108
|
const executeInput = {
|
|
108
109
|
...createProjectContextInputSchema('configuration'),
|
|
109
|
-
|
|
110
|
+
action: z
|
|
111
|
+
.enum(['execute', 'cancel'])
|
|
112
|
+
.optional()
|
|
113
|
+
.describe('Defaults to execute. Cancel terminates retained work; no rollback.'),
|
|
114
|
+
executionId: z
|
|
115
|
+
.string()
|
|
116
|
+
.uuid()
|
|
117
|
+
.optional()
|
|
118
|
+
.describe('Required for cancel; forbidden for execute. Protocol continuation uses requestState, not this field.'),
|
|
119
|
+
code: code.optional(),
|
|
110
120
|
skillRead,
|
|
111
121
|
input: z.unknown().optional().describe('JSON input for async (input); kept separate from saved source.'),
|
|
112
122
|
};
|
|
@@ -164,6 +174,20 @@ export function createScapiCodeTools(loadServices, snippetDirectory) {
|
|
|
164
174
|
try {
|
|
165
175
|
const input = z.object(executeInput).strict().parse(args);
|
|
166
176
|
requireScapiSkill(input.skillRead);
|
|
177
|
+
if (input.action === 'cancel') {
|
|
178
|
+
if (!input.executionId ||
|
|
179
|
+
Object.keys(args).some((key) => !['action', 'executionId', 'skillRead'].includes(key)) ||
|
|
180
|
+
context?.requestState !== undefined ||
|
|
181
|
+
context?.inputResponses !== undefined)
|
|
182
|
+
throw new Error('SCAPI_CANCEL_ARGUMENT_INVALID: cancel requires executionId and skillRead only; omit code, input, project overrides and protocol continuation.');
|
|
183
|
+
return await registry.cancel(input.executionId);
|
|
184
|
+
}
|
|
185
|
+
if (!input.code || input.executionId !== undefined)
|
|
186
|
+
throw new Error('SCAPI_EXECUTE_ARGUMENT_INVALID: execute requires code; omit executionId.');
|
|
187
|
+
if (context?.requestState !== undefined)
|
|
188
|
+
return await registry.retry(input, context);
|
|
189
|
+
if (context?.inputResponses !== undefined)
|
|
190
|
+
throw new Error('SCAPI_CONTINUATION_INVALID: inputResponses require server-issued requestState.');
|
|
167
191
|
const services = await loadServices(input);
|
|
168
192
|
resolution = services.getResolution();
|
|
169
193
|
const config = services.getResolvedConfig();
|
|
@@ -172,36 +196,49 @@ export function createScapiCodeTools(loadServices, snippetDirectory) {
|
|
|
172
196
|
name,
|
|
173
197
|
services.getEnvironmentVariable(name),
|
|
174
198
|
]));
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
auth
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
+
return await registry.start(input, context, async (execution) => {
|
|
200
|
+
let managedRequest;
|
|
201
|
+
const request = async (options, signal) => {
|
|
202
|
+
if (!shortCode || !tenantId)
|
|
203
|
+
throw new Error('SCAPI requires configured shortCode and tenantId. Use config_inspect.');
|
|
204
|
+
managedRequest ??= createScapiRequest({
|
|
205
|
+
shortCode,
|
|
206
|
+
tenantId,
|
|
207
|
+
siteId,
|
|
208
|
+
auth: () => config.createOAuth(),
|
|
209
|
+
documents: loadScapiSchemas(),
|
|
210
|
+
confirm: (request) => execution.confirm(request),
|
|
211
|
+
onDispatch: () => execution.markDispatched(),
|
|
212
|
+
safety: resolveEffectiveSafetyConfig(config.values.safety, loadGlobalSafetyConfig(getB2CConfigDirectory(), safetyEnvironment, resolution?.projectDirectory?.path), safetyEnvironment),
|
|
213
|
+
});
|
|
214
|
+
return managedRequest(options, signal);
|
|
215
|
+
};
|
|
216
|
+
const auth = createScapiAuth(config.values, resolution?.projectDirectory?.path);
|
|
217
|
+
try {
|
|
218
|
+
const result = await runScapiCode({
|
|
219
|
+
code: input.code,
|
|
220
|
+
request: (options, signal) => execution.runCall('request', options, () => request(options, signal)),
|
|
221
|
+
auth: (operation, options, signal) => execution.runCall('auth', undefined, () => auth(operation, options, signal)),
|
|
222
|
+
organizationId: tenantId ? toOrganizationId(tenantId) : undefined,
|
|
223
|
+
siteId,
|
|
224
|
+
cwd: resolution?.projectDirectory?.path,
|
|
225
|
+
signal: execution.controller.signal,
|
|
226
|
+
onControl(control) {
|
|
227
|
+
execution.control = control;
|
|
228
|
+
},
|
|
229
|
+
input: input.input,
|
|
230
|
+
snippets: loadScapiSnippets(snippetDirectory),
|
|
231
|
+
});
|
|
232
|
+
const executionId = execution.id;
|
|
233
|
+
executions.set(executionId, input.code);
|
|
234
|
+
if (executions.size > 50)
|
|
235
|
+
executions.delete(executions.keys().next().value);
|
|
236
|
+
return codeResult({ result, executionId }, resolution);
|
|
237
|
+
}
|
|
238
|
+
catch (error) {
|
|
239
|
+
return failure(error, resolution);
|
|
240
|
+
}
|
|
199
241
|
});
|
|
200
|
-
const executionId = randomUUID();
|
|
201
|
-
executions.set(executionId, input.code);
|
|
202
|
-
if (executions.size > 50)
|
|
203
|
-
executions.delete(executions.keys().next().value);
|
|
204
|
-
return codeResult({ result, executionId }, resolution);
|
|
205
242
|
}
|
|
206
243
|
catch (error) {
|
|
207
244
|
return failure(error, resolution);
|
package/dist/utils/types.d.ts
CHANGED
|
@@ -6,6 +6,15 @@ import type { Toolset } from './constants.js';
|
|
|
6
6
|
* Matches the MCP SDK's CallToolResult type.
|
|
7
7
|
*/
|
|
8
8
|
export type ToolResult = CallToolResult;
|
|
9
|
+
/** Protocol input stays separate from model-supplied tool arguments. */
|
|
10
|
+
export interface ToolContext {
|
|
11
|
+
signal?: AbortSignal;
|
|
12
|
+
requestState?: unknown;
|
|
13
|
+
inputResponses?: Record<string, unknown>;
|
|
14
|
+
supportsElicitation?: boolean;
|
|
15
|
+
/** Legacy elicitation keeps the originating request open between handler entries. */
|
|
16
|
+
keepCancellation?: boolean;
|
|
17
|
+
}
|
|
9
18
|
/** Effects of advertised operations; annotations are derived at registration. */
|
|
10
19
|
export interface ToolEffects {
|
|
11
20
|
effect: 'destructive' | 'read' | 'write';
|
|
@@ -35,9 +44,7 @@ export interface McpToolConfig<T extends ZodRawShape = ZodRawShape> extends Tool
|
|
|
35
44
|
*/
|
|
36
45
|
export interface McpTool<T extends ZodRawShape = ZodRawShape> extends McpToolConfig<T> {
|
|
37
46
|
/** Handler function that executes the tool */
|
|
38
|
-
handler: (args: z.infer<z.ZodObject<T>>, context?:
|
|
39
|
-
signal?: AbortSignal;
|
|
40
|
-
}) => Promise<ToolResult>;
|
|
47
|
+
handler: (args: z.infer<z.ZodObject<T>>, context?: ToolContext) => Promise<ToolResult>;
|
|
41
48
|
}
|
|
42
49
|
/**
|
|
43
50
|
* Startup flags passed to the MCP server.
|
|
@@ -2001,6 +2001,7 @@ type RequestOptions = {
|
|
|
2001
2001
|
/**
|
|
2002
2002
|
* A timeout (in milliseconds) for this request. If exceeded, an {@linkcode SdkError} with code {@linkcode SdkErrorCode.RequestTimeout} will be raised from {@linkcode Protocol.request | request()}.
|
|
2003
2003
|
*
|
|
2004
|
+
* Zero disables the timeout; explicit cancellation remains available.
|
|
2004
2005
|
* If not specified, {@linkcode DEFAULT_REQUEST_TIMEOUT_MSEC} will be used as the timeout.
|
|
2005
2006
|
*/
|
|
2006
2007
|
timeout?: number;
|
|
@@ -2819,7 +2820,7 @@ type ServerOptions = ProtocolOptions & {
|
|
|
2819
2820
|
/**
|
|
2820
2821
|
* Per-leg timeout (ms) for the shim's embedded server→client
|
|
2821
2822
|
* requests, sent with `resetTimeoutOnProgress: true`. Human-paced —
|
|
2822
|
-
* deliberately far above the 60s protocol default.
|
|
2823
|
+
* deliberately far above the 60s protocol default. Zero disables the timeout.
|
|
2823
2824
|
* @default 600_000
|
|
2824
2825
|
*/
|
|
2825
2826
|
roundTimeoutMs?: number;
|
|
@@ -2003,6 +2003,7 @@ type RequestOptions = {
|
|
|
2003
2003
|
/**
|
|
2004
2004
|
* A timeout (in milliseconds) for this request. If exceeded, an {@linkcode SdkError} with code {@linkcode SdkErrorCode.RequestTimeout} will be raised from {@linkcode Protocol.request | request()}.
|
|
2005
2005
|
*
|
|
2006
|
+
* Zero disables the timeout; explicit cancellation remains available.
|
|
2006
2007
|
* If not specified, {@linkcode DEFAULT_REQUEST_TIMEOUT_MSEC} will be used as the timeout.
|
|
2007
2008
|
*/
|
|
2008
2009
|
timeout?: number;
|
|
@@ -2821,7 +2822,7 @@ type ServerOptions = ProtocolOptions & {
|
|
|
2821
2822
|
/**
|
|
2822
2823
|
* Per-leg timeout (ms) for the shim's embedded server→client
|
|
2823
2824
|
* requests, sent with `resetTimeoutOnProgress: true`. Human-paced —
|
|
2824
|
-
* deliberately far above the 60s protocol default.
|
|
2825
|
+
* deliberately far above the 60s protocol default. Zero disables the timeout.
|
|
2825
2826
|
* @default 600_000
|
|
2826
2827
|
*/
|
|
2827
2828
|
roundTimeoutMs?: number;
|
|
@@ -488,7 +488,7 @@ const DEFAULT_LEGACY_SHIM_ROUND_TIMEOUT_MS = 6e5;
|
|
|
488
488
|
/** Resolves and validates `ServerOptions.inputRequired`, failing loudly at construction time. */
|
|
489
489
|
function resolveLegacyShimOptions(options) {
|
|
490
490
|
if (options?.maxRounds !== void 0 && (!Number.isInteger(options.maxRounds) || options.maxRounds < 1)) throw new RangeError(`inputRequired.maxRounds must be a positive integer (got ${options.maxRounds})`);
|
|
491
|
-
if (options?.roundTimeoutMs !== void 0 && (!Number.isFinite(options.roundTimeoutMs) || options.roundTimeoutMs
|
|
491
|
+
if (options?.roundTimeoutMs !== void 0 && (!Number.isFinite(options.roundTimeoutMs) || options.roundTimeoutMs < 0)) throw new RangeError(`inputRequired.roundTimeoutMs must be a non-negative number (got ${options.roundTimeoutMs})`);
|
|
492
492
|
return {
|
|
493
493
|
maxRounds: options?.maxRounds ?? DEFAULT_LEGACY_SHIM_MAX_ROUNDS,
|
|
494
494
|
roundTimeoutMs: options?.roundTimeoutMs ?? DEFAULT_LEGACY_SHIM_ROUND_TIMEOUT_MS,
|
|
@@ -486,7 +486,7 @@ const DEFAULT_LEGACY_SHIM_ROUND_TIMEOUT_MS = 6e5;
|
|
|
486
486
|
/** Resolves and validates `ServerOptions.inputRequired`, failing loudly at construction time. */
|
|
487
487
|
function resolveLegacyShimOptions(options) {
|
|
488
488
|
if (options?.maxRounds !== void 0 && (!Number.isInteger(options.maxRounds) || options.maxRounds < 1)) throw new RangeError(`inputRequired.maxRounds must be a positive integer (got ${options.maxRounds})`);
|
|
489
|
-
if (options?.roundTimeoutMs !== void 0 && (!Number.isFinite(options.roundTimeoutMs) || options.roundTimeoutMs
|
|
489
|
+
if (options?.roundTimeoutMs !== void 0 && (!Number.isFinite(options.roundTimeoutMs) || options.roundTimeoutMs < 0)) throw new RangeError(`inputRequired.roundTimeoutMs must be a non-negative number (got ${options.roundTimeoutMs})`);
|
|
490
490
|
return {
|
|
491
491
|
maxRounds: options?.maxRounds ?? DEFAULT_LEGACY_SHIM_MAX_ROUNDS,
|
|
492
492
|
roundTimeoutMs: options?.roundTimeoutMs ?? DEFAULT_LEGACY_SHIM_ROUND_TIMEOUT_MS,
|
|
@@ -6251,6 +6251,8 @@ var Protocol = class {
|
|
|
6251
6251
|
this._requestHandlerAbortControllers.get(notification.params.requestId)?.abort(notification.params.reason);
|
|
6252
6252
|
}
|
|
6253
6253
|
_setupTimeout(messageId, timeout, maxTotalTimeout, onTimeout, resetTimeoutOnProgress = false) {
|
|
6254
|
+
// Zero disables the deadline for human-paced elicitation; cancellation remains active.
|
|
6255
|
+
if (timeout === 0) return;
|
|
6254
6256
|
this._timeoutInfo.set(messageId, {
|
|
6255
6257
|
timeoutId: setTimeout(onTimeout, timeout),
|
|
6256
6258
|
startTime: Date.now(),
|