@fluentcommerce/fluent-mcp-extn 0.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/LICENSE +21 -0
- package/README.md +818 -0
- package/dist/config.js +195 -0
- package/dist/entity-registry.js +418 -0
- package/dist/entity-tools.js +414 -0
- package/dist/environment-tools.js +573 -0
- package/dist/errors.js +150 -0
- package/dist/event-payload.js +22 -0
- package/dist/fluent-client.js +229 -0
- package/dist/index.js +47 -0
- package/dist/resilience.js +52 -0
- package/dist/response-shaper.js +361 -0
- package/dist/sdk-client.js +237 -0
- package/dist/settings-tools.js +348 -0
- package/dist/test-tools.js +388 -0
- package/dist/tools.js +3254 -0
- package/dist/workflow-tools.js +752 -0
- package/docs/CONTRIBUTING.md +100 -0
- package/docs/E2E_TESTING.md +739 -0
- package/docs/HANDOVER_COPILOT_SETUP_STEPS.example.yml +35 -0
- package/docs/HANDOVER_ENV.example +29 -0
- package/docs/HANDOVER_GITHUB_COPILOT.md +165 -0
- package/docs/HANDOVER_GITHUB_REPO_MCP_CONFIG.example.json +31 -0
- package/docs/HANDOVER_VSCODE_MCP_JSON.example.json +10 -0
- package/docs/IMPLEMENTATION_GUIDE.md +299 -0
- package/docs/RUNBOOK.md +312 -0
- package/docs/TOOL_REFERENCE.md +1810 -0
- package/package.json +68 -0
package/dist/errors.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed application error that captures an explicit code and retry hint.
|
|
3
|
+
*/
|
|
4
|
+
export class ToolError extends Error {
|
|
5
|
+
code;
|
|
6
|
+
retryable;
|
|
7
|
+
details;
|
|
8
|
+
constructor(code, message, options) {
|
|
9
|
+
super(message, options?.cause ? { cause: options.cause } : undefined);
|
|
10
|
+
this.name = "ToolError";
|
|
11
|
+
this.code = code;
|
|
12
|
+
this.retryable = options?.retryable ?? false;
|
|
13
|
+
this.details = options?.details;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Extract an HTTP status code from an error object.
|
|
18
|
+
* SDK errors commonly expose status via `.statusCode`, `.status`, or
|
|
19
|
+
* `.response.status` — all of which are safer than substring matching
|
|
20
|
+
* on the free-form message text.
|
|
21
|
+
*/
|
|
22
|
+
function extractHttpStatus(error) {
|
|
23
|
+
if (!error || typeof error !== "object")
|
|
24
|
+
return null;
|
|
25
|
+
const rec = error;
|
|
26
|
+
// Nested response.status (axios-style errors)
|
|
27
|
+
const resp = rec.response;
|
|
28
|
+
if (resp && typeof resp === "object") {
|
|
29
|
+
const respRec = resp;
|
|
30
|
+
const val = respRec.status ?? respRec.statusCode;
|
|
31
|
+
if (typeof val === "number" && val >= 100 && val < 600)
|
|
32
|
+
return val;
|
|
33
|
+
if (typeof val === "string") {
|
|
34
|
+
const parsed = Number(val.trim());
|
|
35
|
+
if (Number.isFinite(parsed) && parsed >= 100 && parsed < 600)
|
|
36
|
+
return parsed;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
// Top-level statusCode is common in Node HTTP errors; avoid generic top-level
|
|
40
|
+
// "status" fields because they often represent non-HTTP domain statuses.
|
|
41
|
+
const statusCode = rec.statusCode;
|
|
42
|
+
if (typeof statusCode === "number" && statusCode >= 100 && statusCode < 600) {
|
|
43
|
+
return statusCode;
|
|
44
|
+
}
|
|
45
|
+
if (typeof statusCode === "string") {
|
|
46
|
+
const parsed = Number(statusCode.trim());
|
|
47
|
+
if (Number.isFinite(parsed) && parsed >= 100 && parsed < 600) {
|
|
48
|
+
return parsed;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Maps unknown runtime errors to our stable tool error contract.
|
|
55
|
+
*
|
|
56
|
+
* Classification priority:
|
|
57
|
+
* 1. ToolError pass-through
|
|
58
|
+
* 2. HTTP status code from error properties (safe, no false positives)
|
|
59
|
+
* 3. Error code string (Node.js network errors like ECONNRESET)
|
|
60
|
+
* 4. Keyword-based message classification (conservative patterns)
|
|
61
|
+
* 5. Fallback to SDK_ERROR or UNKNOWN_ERROR
|
|
62
|
+
*/
|
|
63
|
+
function normalizeUnknownError(error) {
|
|
64
|
+
if (error instanceof ToolError)
|
|
65
|
+
return error;
|
|
66
|
+
const message = error instanceof Error
|
|
67
|
+
? error.message
|
|
68
|
+
: error && typeof error === "object" && typeof error.message === "string"
|
|
69
|
+
? String(error.message)
|
|
70
|
+
: String(error);
|
|
71
|
+
const normalized = message.toLowerCase();
|
|
72
|
+
// ---- Phase 1: HTTP status code from error properties (safe) ----
|
|
73
|
+
const httpStatus = extractHttpStatus(error);
|
|
74
|
+
if (httpStatus !== null) {
|
|
75
|
+
if (httpStatus === 401 || httpStatus === 403) {
|
|
76
|
+
return new ToolError("AUTH_ERROR", message, { retryable: false });
|
|
77
|
+
}
|
|
78
|
+
if (httpStatus === 429) {
|
|
79
|
+
return new ToolError("RATE_LIMIT", message, { retryable: true });
|
|
80
|
+
}
|
|
81
|
+
if (httpStatus >= 500 && httpStatus <= 599) {
|
|
82
|
+
return new ToolError("UPSTREAM_UNAVAILABLE", message, { retryable: true });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// ---- Phase 2: Error code (Node.js system errors) ----
|
|
86
|
+
const errorCode = error && typeof error === "object" ? error.code : undefined;
|
|
87
|
+
if (typeof errorCode === "string") {
|
|
88
|
+
const code = errorCode.toUpperCase();
|
|
89
|
+
if (code === "ECONNRESET" ||
|
|
90
|
+
code === "ECONNREFUSED" ||
|
|
91
|
+
code === "EAI_AGAIN" ||
|
|
92
|
+
code === "ENOTFOUND" ||
|
|
93
|
+
code === "ETIMEDOUT" ||
|
|
94
|
+
code === "EPIPE") {
|
|
95
|
+
return new ToolError("NETWORK_ERROR", message, { retryable: true });
|
|
96
|
+
}
|
|
97
|
+
if (code === "ABORT_ERR" || code === "ETIMEOUT") {
|
|
98
|
+
return new ToolError("TIMEOUT_ERROR", message, { retryable: true });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// ---- Phase 3: Keyword-based classification (conservative patterns) ----
|
|
102
|
+
if (/\bzod(error)?\b/i.test(message)) {
|
|
103
|
+
return new ToolError("VALIDATION_ERROR", message, { retryable: false });
|
|
104
|
+
}
|
|
105
|
+
if (/\btimeout\b/i.test(message) || normalized.includes("timed out")) {
|
|
106
|
+
return new ToolError("TIMEOUT_ERROR", message, { retryable: true });
|
|
107
|
+
}
|
|
108
|
+
if (/\brate[\s-]?limit(?:ed|ing)?\b/i.test(message) ||
|
|
109
|
+
normalized.includes("too many requests")) {
|
|
110
|
+
return new ToolError("RATE_LIMIT", message, { retryable: true });
|
|
111
|
+
}
|
|
112
|
+
if (normalized.includes("econnreset") ||
|
|
113
|
+
normalized.includes("econnrefused") ||
|
|
114
|
+
normalized.includes("eai_again") ||
|
|
115
|
+
normalized.includes("enotfound") ||
|
|
116
|
+
normalized.includes("network error") ||
|
|
117
|
+
normalized.includes("fetch failed")) {
|
|
118
|
+
return new ToolError("NETWORK_ERROR", message, { retryable: true });
|
|
119
|
+
}
|
|
120
|
+
// ---- Phase 4: Fallback ----
|
|
121
|
+
if (error instanceof Error) {
|
|
122
|
+
return new ToolError("SDK_ERROR", error.message, {
|
|
123
|
+
retryable: false,
|
|
124
|
+
cause: error,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
return new ToolError("UNKNOWN_ERROR", message, { retryable: false });
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Single source of truth for retryability.
|
|
131
|
+
* Normalizes unknown errors and checks the retryable flag.
|
|
132
|
+
*/
|
|
133
|
+
export function isRetryable(error) {
|
|
134
|
+
return normalizeUnknownError(error).retryable;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Converts any thrown value into a MCP-safe failure payload.
|
|
138
|
+
*/
|
|
139
|
+
export function toToolFailure(error) {
|
|
140
|
+
const normalized = normalizeUnknownError(error);
|
|
141
|
+
return {
|
|
142
|
+
ok: false,
|
|
143
|
+
error: {
|
|
144
|
+
code: normalized.code,
|
|
145
|
+
message: normalized.message,
|
|
146
|
+
retryable: normalized.retryable,
|
|
147
|
+
details: normalized.details,
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds a normalized event payload using input overrides plus config defaults.
|
|
3
|
+
*/
|
|
4
|
+
export function buildEventPayload(input, config) {
|
|
5
|
+
// MCP tool input is JSON-compatible; we cast to SDK AttributeValue shape.
|
|
6
|
+
const attributes = (input.attributes ?? {});
|
|
7
|
+
return {
|
|
8
|
+
name: input.name,
|
|
9
|
+
entityRef: input.entityRef,
|
|
10
|
+
entityId: input.entityId,
|
|
11
|
+
entityType: input.entityType,
|
|
12
|
+
entitySubtype: input.entitySubtype,
|
|
13
|
+
rootEntityRef: input.rootEntityRef ?? input.entityRef,
|
|
14
|
+
rootEntityType: input.rootEntityType ?? "ORDER",
|
|
15
|
+
rootEntityId: input.rootEntityId,
|
|
16
|
+
retailerId: input.retailerId ?? config.retailerId ?? undefined,
|
|
17
|
+
accountId: input.accountId ?? config.accountId ?? undefined,
|
|
18
|
+
source: input.source ?? "fluent-mcp-extn",
|
|
19
|
+
type: input.type ?? "NORMAL",
|
|
20
|
+
attributes,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { ToolError } from "./errors.js";
|
|
2
|
+
import { withRetry, withTimeout } from "./resilience.js";
|
|
3
|
+
function hasFunction(target, key) {
|
|
4
|
+
if (!target || typeof target !== "object")
|
|
5
|
+
return false;
|
|
6
|
+
const value = target[key];
|
|
7
|
+
return typeof value === "function";
|
|
8
|
+
}
|
|
9
|
+
function toSdkClientSurface(rawClient) {
|
|
10
|
+
if (hasFunction(rawClient, "sendEvent") &&
|
|
11
|
+
hasFunction(rawClient, "getEvents") &&
|
|
12
|
+
hasFunction(rawClient, "getEventById") &&
|
|
13
|
+
hasFunction(rawClient, "graphql") &&
|
|
14
|
+
hasFunction(rawClient, "createJob") &&
|
|
15
|
+
hasFunction(rawClient, "sendBatch") &&
|
|
16
|
+
hasFunction(rawClient, "getJobStatus") &&
|
|
17
|
+
hasFunction(rawClient, "getBatchStatus") &&
|
|
18
|
+
hasFunction(rawClient, "getJobResults")) {
|
|
19
|
+
return rawClient;
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Typed wrapper around SDK APIs with shared timeout/retry behavior.
|
|
25
|
+
*/
|
|
26
|
+
export class FluentClientAdapter {
|
|
27
|
+
client;
|
|
28
|
+
config;
|
|
29
|
+
retryPolicy;
|
|
30
|
+
constructor(client, config) {
|
|
31
|
+
this.client = client;
|
|
32
|
+
this.config = config;
|
|
33
|
+
this.retryPolicy = {
|
|
34
|
+
attempts: config.retryAttempts,
|
|
35
|
+
initialDelayMs: config.retryInitialDelayMs,
|
|
36
|
+
maxDelayMs: config.retryMaxDelayMs,
|
|
37
|
+
factor: config.retryFactor,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Read-path wrapper: timeout + retry with exponential backoff.
|
|
42
|
+
* Safe for idempotent reads (getEvents, getEventById, graphql queries,
|
|
43
|
+
* getJobStatus, getBatchStatus, getJobResults).
|
|
44
|
+
*/
|
|
45
|
+
async execute(operationName, operation) {
|
|
46
|
+
return withRetry(operationName, this.retryPolicy, () => withTimeout(operationName, this.config.requestTimeoutMs, operation));
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Write-path wrapper: timeout only, NO retry.
|
|
50
|
+
* Prevents duplicate side effects for non-idempotent operations
|
|
51
|
+
* (sendEvent, createJob, sendBatch, batchMutate).
|
|
52
|
+
*
|
|
53
|
+
* Timeout errors are marked non-retryable so callers don't
|
|
54
|
+
* accidentally retry them either.
|
|
55
|
+
*/
|
|
56
|
+
async executeOnce(operationName, operation) {
|
|
57
|
+
return withTimeout(operationName, this.config.requestTimeoutMs, operation);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Long-running wrapper: custom timeout with retry.
|
|
61
|
+
* Used for auto-paginated queries that may take longer than a single request.
|
|
62
|
+
*/
|
|
63
|
+
async executeLong(operationName, timeoutMs, operation) {
|
|
64
|
+
return withRetry(operationName, this.retryPolicy, () => withTimeout(operationName, timeoutMs, operation));
|
|
65
|
+
}
|
|
66
|
+
// ---- Write operations (executeOnce — no retry) --------------------------
|
|
67
|
+
async sendEvent(event, mode = "async") {
|
|
68
|
+
return this.executeOnce("sendEvent", () => this.client.sendEvent(event, mode));
|
|
69
|
+
}
|
|
70
|
+
async createJob(payload) {
|
|
71
|
+
return this.executeOnce("createJob", () => this.client.createJob(payload));
|
|
72
|
+
}
|
|
73
|
+
async sendBatch(jobId, payload) {
|
|
74
|
+
return this.executeOnce("sendBatch", () => this.client.sendBatch(jobId, payload));
|
|
75
|
+
}
|
|
76
|
+
// ---- Read operations (execute — timeout + retry) ------------------------
|
|
77
|
+
async getEvents(params = {}) {
|
|
78
|
+
return this.execute("getEvents", () => this.client.getEvents(params));
|
|
79
|
+
}
|
|
80
|
+
async getEventById(eventId) {
|
|
81
|
+
return this.execute("getEventById", () => this.client.getEventById(eventId));
|
|
82
|
+
}
|
|
83
|
+
async graphql(payload, options) {
|
|
84
|
+
if (options?.retry === false) {
|
|
85
|
+
return this.executeOnce("graphql", () => this.client.graphql(payload));
|
|
86
|
+
}
|
|
87
|
+
return this.execute("graphql", () => this.client.graphql(payload));
|
|
88
|
+
}
|
|
89
|
+
async getJobStatus(jobId) {
|
|
90
|
+
return this.execute("getJobStatus", () => this.client.getJobStatus(jobId));
|
|
91
|
+
}
|
|
92
|
+
async getBatchStatus(jobId, batchId) {
|
|
93
|
+
return this.execute("getBatchStatus", () => this.client.getBatchStatus(jobId, batchId));
|
|
94
|
+
}
|
|
95
|
+
async getJobResults(jobId) {
|
|
96
|
+
return this.execute("getJobResults", () => this.client.getJobResults(jobId));
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Require the raw `request` method on the SDK client.
|
|
100
|
+
* Used for APIs not yet wrapped by first-class SDK methods.
|
|
101
|
+
*/
|
|
102
|
+
requireRequestClient(apiName) {
|
|
103
|
+
if (!hasFunction(this.client, "request")) {
|
|
104
|
+
throw new ToolError("SDK_ERROR", `${apiName} is not supported by current SDK client (request method missing).`);
|
|
105
|
+
}
|
|
106
|
+
return this.client;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Unwrap SDK response: if it has a `data` property, return that; otherwise the whole response.
|
|
110
|
+
*/
|
|
111
|
+
static unwrapResponse(response) {
|
|
112
|
+
if (response && typeof response === "object" && "data" in response) {
|
|
113
|
+
return response.data;
|
|
114
|
+
}
|
|
115
|
+
return response;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Query User Action transitions from /api/v4.1/transition.
|
|
119
|
+
* Although this endpoint uses POST, it is read-only and safe to retry.
|
|
120
|
+
*/
|
|
121
|
+
async getTransitions(payload) {
|
|
122
|
+
const requestClient = this.requireRequestClient("Transition API");
|
|
123
|
+
return this.execute("getTransitions", async () => {
|
|
124
|
+
const response = await requestClient.request("/api/v4.1/transition", {
|
|
125
|
+
method: "POST",
|
|
126
|
+
body: payload,
|
|
127
|
+
});
|
|
128
|
+
return FluentClientAdapter.unwrapResponse(response);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Fetch the orchestration plugin/rule registry from /orchestration/rest/v1/plugin.
|
|
133
|
+
* Returns a map of rule names → metadata (description, accepts, produces, params).
|
|
134
|
+
* Read-only — safe to retry.
|
|
135
|
+
*/
|
|
136
|
+
async getPlugins() {
|
|
137
|
+
const requestClient = this.requireRequestClient("Plugin API");
|
|
138
|
+
return this.execute("getPlugins", async () => {
|
|
139
|
+
const response = await requestClient.request("/orchestration/rest/v1/plugin", { method: "GET" });
|
|
140
|
+
return FluentClientAdapter.unwrapResponse(response);
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Query Prometheus metrics via GraphQL metricInstant / metricRange queries.
|
|
145
|
+
* The Fluent platform does NOT expose raw Prometheus REST endpoints
|
|
146
|
+
* (/api/v1/query returns 400). All PromQL goes through the GraphQL proxy.
|
|
147
|
+
* Read-only operation; safe to retry.
|
|
148
|
+
*/
|
|
149
|
+
async queryPrometheus(payload) {
|
|
150
|
+
const isRange = payload.type === "range";
|
|
151
|
+
const query = isRange
|
|
152
|
+
? `query MetricRange($query: String!, $start: DateTime!, $end: DateTime!, $step: String!) {
|
|
153
|
+
metricRange(query: $query, start: $start, end: $end, step: $step) {
|
|
154
|
+
status
|
|
155
|
+
data { resultType result { metric values } }
|
|
156
|
+
errorType error warnings
|
|
157
|
+
}
|
|
158
|
+
}`
|
|
159
|
+
: `query MetricInstant($query: String!${payload.time ? ", $time: DateTime" : ""}) {
|
|
160
|
+
metricInstant(query: $query${payload.time ? ", time: $time" : ""}) {
|
|
161
|
+
status
|
|
162
|
+
data { resultType result { metric value } }
|
|
163
|
+
errorType error warnings
|
|
164
|
+
}
|
|
165
|
+
}`;
|
|
166
|
+
const variables = { query: payload.query };
|
|
167
|
+
if (isRange) {
|
|
168
|
+
if (payload.start)
|
|
169
|
+
variables.start = payload.start;
|
|
170
|
+
if (payload.end)
|
|
171
|
+
variables.end = payload.end;
|
|
172
|
+
if (payload.step)
|
|
173
|
+
variables.step = payload.step;
|
|
174
|
+
}
|
|
175
|
+
else if (payload.time) {
|
|
176
|
+
variables.time = payload.time;
|
|
177
|
+
}
|
|
178
|
+
return this.execute("queryPrometheus", async () => {
|
|
179
|
+
const gqlResponse = await this.client.graphql({
|
|
180
|
+
query,
|
|
181
|
+
variables,
|
|
182
|
+
});
|
|
183
|
+
// Extract the metricInstant or metricRange result
|
|
184
|
+
const data = gqlResponse?.data;
|
|
185
|
+
const metricResult = data?.[isRange ? "metricRange" : "metricInstant"];
|
|
186
|
+
if (metricResult && typeof metricResult === "object" && "status" in metricResult) {
|
|
187
|
+
return metricResult;
|
|
188
|
+
}
|
|
189
|
+
// Handle GraphQL errors
|
|
190
|
+
const errors = gqlResponse?.errors;
|
|
191
|
+
if (errors) {
|
|
192
|
+
return {
|
|
193
|
+
status: "error",
|
|
194
|
+
errorType: "graphql",
|
|
195
|
+
error: JSON.stringify(errors),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
return { status: "success", data: metricResult ?? data };
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
// ---- Long-running operations (executeLong — custom timeout + retry) -----
|
|
202
|
+
/**
|
|
203
|
+
* Execute a GraphQL query with explicit pagination config.
|
|
204
|
+
* Uses the pagination timeoutMs (or a generous default) instead of the
|
|
205
|
+
* per-request timeout, since multi-page fetches can legitimately run
|
|
206
|
+
* for minutes.
|
|
207
|
+
*/
|
|
208
|
+
async graphqlPaginated(payload) {
|
|
209
|
+
const paginationTimeout = payload.pagination?.timeoutMs ?? 300_000; // 5 min default
|
|
210
|
+
return this.executeLong("graphqlPaginated", paginationTimeout, () => this.client.graphql(payload));
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Returns a lightweight object implementing GraphQLCapable.
|
|
214
|
+
* Standalone SDK services (FluentConnectionTester, GraphQLIntrospectionService)
|
|
215
|
+
* only need graphql() — this adapter satisfies that contract.
|
|
216
|
+
*/
|
|
217
|
+
asGraphQLCapable() {
|
|
218
|
+
return {
|
|
219
|
+
graphql: (payload) => this.graphql(payload),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
export function createFluentClientAdapter(rawClient, config) {
|
|
224
|
+
const client = toSdkClientSurface(rawClient);
|
|
225
|
+
if (!client) {
|
|
226
|
+
throw new ToolError("SDK_ERROR", "SDK client does not expose the required methods for MCP tools.");
|
|
227
|
+
}
|
|
228
|
+
return new FluentClientAdapter(client, config);
|
|
229
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
import { loadConfig } from "./config.js";
|
|
6
|
+
import { loadResponseBudget } from "./response-shaper.js";
|
|
7
|
+
import { initSDKClient } from "./sdk-client.js";
|
|
8
|
+
import { registerToolHandlers, TOOL_NAMES } from "./tools.js";
|
|
9
|
+
const require = createRequire(import.meta.url);
|
|
10
|
+
const { version: PKG_VERSION } = require("../package.json");
|
|
11
|
+
/**
|
|
12
|
+
* Process entrypoint:
|
|
13
|
+
* - load runtime config
|
|
14
|
+
* - initialize SDK adapter
|
|
15
|
+
* - register MCP tools
|
|
16
|
+
* - attach stdio transport
|
|
17
|
+
*/
|
|
18
|
+
async function start() {
|
|
19
|
+
const config = await loadConfig();
|
|
20
|
+
const client = await initSDKClient(config);
|
|
21
|
+
const server = new Server({
|
|
22
|
+
name: "fluent-mcp-extn",
|
|
23
|
+
version: PKG_VERSION,
|
|
24
|
+
}, {
|
|
25
|
+
capabilities: {
|
|
26
|
+
tools: {},
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
const responseBudget = loadResponseBudget();
|
|
30
|
+
registerToolHandlers(server, { client, config, responseBudget });
|
|
31
|
+
const transport = new StdioServerTransport();
|
|
32
|
+
await server.connect(transport);
|
|
33
|
+
console.error(`[fluent-mcp-extn] v${PKG_VERSION} running on stdio`);
|
|
34
|
+
console.error(`[fluent-mcp-extn] tools: ${TOOL_NAMES.join(", ")}`);
|
|
35
|
+
console.error(`[fluent-mcp-extn] sdk adapter: ${client ? "ready" : "not configured"}`);
|
|
36
|
+
const shutdown = async () => {
|
|
37
|
+
await server.close();
|
|
38
|
+
process.exit(0);
|
|
39
|
+
};
|
|
40
|
+
process.on("SIGINT", shutdown);
|
|
41
|
+
process.on("SIGTERM", shutdown);
|
|
42
|
+
}
|
|
43
|
+
start().catch((error) => {
|
|
44
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
45
|
+
console.error(`[fluent-mcp-extn] failed to start: ${message}`);
|
|
46
|
+
process.exit(1);
|
|
47
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { ToolError, isRetryable } from "./errors.js";
|
|
2
|
+
function sleep(ms) {
|
|
3
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Wraps async operations with a hard timeout.
|
|
7
|
+
*/
|
|
8
|
+
export async function withTimeout(operationName, timeoutMs, operation) {
|
|
9
|
+
let timeoutId = null;
|
|
10
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
11
|
+
timeoutId = setTimeout(() => {
|
|
12
|
+
reject(new ToolError("TIMEOUT_ERROR", `${operationName} timed out after ${timeoutMs}ms`, { retryable: true }));
|
|
13
|
+
}, timeoutMs);
|
|
14
|
+
});
|
|
15
|
+
try {
|
|
16
|
+
return await Promise.race([operation(), timeoutPromise]);
|
|
17
|
+
}
|
|
18
|
+
finally {
|
|
19
|
+
if (timeoutId)
|
|
20
|
+
clearTimeout(timeoutId);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Retries transient failures using exponential backoff.
|
|
25
|
+
*/
|
|
26
|
+
export async function withRetry(operationName, policy, operation) {
|
|
27
|
+
// 0 = single attempt with no retry; minimum 1 actual execution.
|
|
28
|
+
const attempts = Math.max(1, policy.attempts);
|
|
29
|
+
let lastError;
|
|
30
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
31
|
+
try {
|
|
32
|
+
return await operation();
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
lastError = error;
|
|
36
|
+
if (attempt >= attempts || !isRetryable(error))
|
|
37
|
+
break;
|
|
38
|
+
// Exponential backoff with jitter and a hard cap.
|
|
39
|
+
const baseDelay = Math.min(policy.maxDelayMs, Math.round(policy.initialDelayMs * Math.pow(policy.factor, attempt - 1)));
|
|
40
|
+
const delay = Math.round(baseDelay * (0.5 + Math.random() * 0.5));
|
|
41
|
+
await sleep(delay);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (lastError instanceof ToolError)
|
|
45
|
+
throw lastError;
|
|
46
|
+
if (lastError instanceof Error)
|
|
47
|
+
throw lastError;
|
|
48
|
+
throw new ToolError("SDK_ERROR", `${operationName} failed after retries`, {
|
|
49
|
+
retryable: false,
|
|
50
|
+
details: { attempts },
|
|
51
|
+
});
|
|
52
|
+
}
|