fettle-mcp 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/dist/config.js ADDED
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Runtime configuration, read once at startup from the environment.
3
+ *
4
+ * An MCP server is spawned by its client with an env block from a JSON config
5
+ * file the user hand-edits, so a missing or malformed value is the single most
6
+ * likely failure of this package — and the worst place to discover it is
7
+ * inside the first tool call, where the model reports it to the user as if the
8
+ * *monitor* were the problem. Everything here therefore fails at startup, on
9
+ * stderr, naming the variable and the file to fix.
10
+ */
11
+ /** Where the public API lives when `FETTLE_API_URL` is not set. */
12
+ export const DEFAULT_API_URL = "https://api.fettle.sh";
13
+ /**
14
+ * How long one tool call may take before it is abandoned.
15
+ *
16
+ * A hung request is worse here than a slow one: the MCP client has no timeout
17
+ * of its own for a stdio server, so an unbounded `fetch` presents to the user
18
+ * as a permanently frozen assistant. 30s is well past the API's own latency
19
+ * and well short of a human's patience.
20
+ */
21
+ export const DEFAULT_TIMEOUT_MS = 30_000;
22
+ export class ConfigError extends Error {
23
+ }
24
+ function readApiUrl(raw) {
25
+ if (raw === undefined || raw.trim() === "") {
26
+ return DEFAULT_API_URL;
27
+ }
28
+ let parsed;
29
+ try {
30
+ parsed = new URL(raw.trim());
31
+ }
32
+ catch {
33
+ throw new ConfigError(`FETTLE_API_URL is not a valid URL: ${raw}. Leave it unset to use ` +
34
+ `${DEFAULT_API_URL}.`);
35
+ }
36
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
37
+ throw new ConfigError(`FETTLE_API_URL must be an http(s) URL; got ${parsed.protocol}//. Leave ` +
38
+ `it unset to use ${DEFAULT_API_URL}.`);
39
+ }
40
+ // Trailing slashes are stripped because every path in the contract is
41
+ // absolute and already leads with one — concatenating both would produce
42
+ // `//v1/monitors`, which the API answers with a 404 rather than the route
43
+ // the user plainly meant.
44
+ return parsed.toString().replace(/\/+$/, "");
45
+ }
46
+ function readTimeout(raw) {
47
+ if (raw === undefined || raw.trim() === "") {
48
+ return DEFAULT_TIMEOUT_MS;
49
+ }
50
+ const parsed = Number.parseInt(raw.trim(), 10);
51
+ if (!Number.isFinite(parsed) || parsed <= 0) {
52
+ throw new ConfigError(`FETTLE_TIMEOUT_MS must be a positive whole number of milliseconds; ` +
53
+ `got ${raw}. Leave it unset to use ${DEFAULT_TIMEOUT_MS}.`);
54
+ }
55
+ return parsed;
56
+ }
57
+ /**
58
+ * Build the configuration or explain what is missing.
59
+ *
60
+ * Throws:
61
+ * ConfigError: `FETTLE_API_KEY` is absent or blank, `FETTLE_API_URL` is not a
62
+ * usable http(s) URL, or `FETTLE_TIMEOUT_MS` is not a positive integer.
63
+ */
64
+ export function loadConfig(env = process.env) {
65
+ const apiKey = env.FETTLE_API_KEY?.trim();
66
+ if (apiKey === undefined || apiKey === "") {
67
+ throw new ConfigError("FETTLE_API_KEY is not set. Create an API key in Settings → API Keys at " +
68
+ "https://fettle.sh and add it to the fettle-mcp entry of your MCP " +
69
+ 'client configuration, e.g. "env": { "FETTLE_API_KEY": "fettle_…" }.');
70
+ }
71
+ return {
72
+ apiKey,
73
+ apiUrl: readApiUrl(env.FETTLE_API_URL),
74
+ timeoutMs: readTimeout(env.FETTLE_TIMEOUT_MS),
75
+ };
76
+ }
77
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,mEAAmE;AACnE,MAAM,CAAC,MAAM,eAAe,GAAG,uBAAuB,CAAC;AAEvD;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAAG,MAAM,CAAC;AAQzC,MAAM,OAAO,WAAY,SAAQ,KAAK;CAAG;AAEzC,SAAS,UAAU,CAAC,GAAuB;IACzC,IAAI,GAAG,KAAK,SAAS,IAAI,GAAG,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE,CAAC;QAC3C,OAAO,eAAe,CAAC;IACzB,CAAC;IACD,IAAI,MAAW,CAAC;IAChB,IAAI,CAAC;QACH,MAAM,GAAG,IAAI,GAAG,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC,CAAC;IAC/B,CAAC;IAAC,MAAM,CAAC;QACP,MAAM,IAAI,WAAW,CACnB,sCAAsC,GAAG,0BAA0B;YACjE,GAAG,eAAe,GAAG,CACxB,CAAC;IACJ,CAAC;IACD,IAAI,MAAM,CAAC,QAAQ,KAAK,QAAQ,IAAI,MAAM,CAAC,QAAQ,KAAK,OAAO,EAAE,CAAC;QAChE,MAAM,IAAI,WAAW,CACnB,8CAA8C,MAAM,CAAC,QAAQ,YAAY;YACvE,mBAAmB,eAAe,GAAG,CACxC,CAAC;IACJ,CAAC;IACD,sEAAsE;IACtE,yEAAyE;IACzE,0EAA0E;IAC1E,0BAA0B;IAC1B,OAAO,MAAM,CAAC,QAAQ,EAAE,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;AAC/C,CAAC;AAED,SAAS,WAAW,CAAC,GAAuB;IAC1C,IAAI,GAAG,KAAK,SAAS,IAAI,GAAG,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE,CAAC;QAC3C,OAAO,kBAAkB,CAAC;IAC5B,CAAC;IACD,MAAM,MAAM,GAAG,MAAM,CAAC,QAAQ,CAAC,GAAG,CAAC,IAAI,EAAE,EAAE,EAAE,CAAC,CAAC;IAC/C,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,IAAI,MAAM,IAAI,CAAC,EAAE,CAAC;QAC5C,MAAM,IAAI,WAAW,CACnB,qEAAqE;YACnE,OAAO,GAAG,2BAA2B,kBAAkB,GAAG,CAC7D,CAAC;IACJ,CAAC;IACD,OAAO,MAAM,CAAC;AAChB,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,UAAU,CAAC,MAAyB,OAAO,CAAC,GAAG;IAC7D,MAAM,MAAM,GAAG,GAAG,CAAC,cAAc,EAAE,IAAI,EAAE,CAAC;IAC1C,IAAI,MAAM,KAAK,SAAS,IAAI,MAAM,KAAK,EAAE,EAAE,CAAC;QAC1C,MAAM,IAAI,WAAW,CACnB,yEAAyE;YACvE,mEAAmE;YACnE,qEAAqE,CACxE,CAAC;IACJ,CAAC;IACD,OAAO;QACL,MAAM;QACN,MAAM,EAAE,UAAU,CAAC,GAAG,CAAC,cAAc,CAAC;QACtC,SAAS,EAAE,WAAW,CAAC,GAAG,CAAC,iBAAiB,CAAC;KAC9C,CAAC;AACJ,CAAC"}
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The tool contract: the one description both Fettle MCP channels interpret.
3
+ *
4
+ * The file this module loads is *generated* in the core backend
5
+ * (`scripts/generate_mcp_contract.py` from `src/mcp/registry.py`) and vendored
6
+ * here verbatim. Nothing in this package describes a tool by hand: adding a
7
+ * tool means re-vendoring the contract, not editing source. `pnpm
8
+ * contract:check` compares the vendored copy against what production serves at
9
+ * `GET /public-api/mcp-tools.json`, so drift is a failing check rather than a
10
+ * silent difference between the two channels.
11
+ */
12
+ /** How to execute a tool as an HTTP request against the public API. */
13
+ export interface ToolOperation {
14
+ method: "GET" | "POST" | "PATCH" | "DELETE";
15
+ path: string;
16
+ path_params: string[];
17
+ query: string[];
18
+ body: boolean;
19
+ }
20
+ /** One agent-facing tool. */
21
+ export interface ToolEntry {
22
+ name: string;
23
+ title: string;
24
+ description: string;
25
+ scope: "read" | "write";
26
+ input_schema: Record<string, unknown>;
27
+ operation: ToolOperation;
28
+ }
29
+ /** The whole document. */
30
+ export interface ToolContract {
31
+ version: number;
32
+ tools: ToolEntry[];
33
+ }
34
+ /**
35
+ * The contract format version this code understands.
36
+ *
37
+ * Kept in lockstep with core's `SUPPORTED_CONTRACT_VERSION`. A guard, not a
38
+ * migration path — a future format bump surfaces as one legible message
39
+ * instead of an undefined field read at the first tool call.
40
+ */
41
+ export declare const SUPPORTED_CONTRACT_VERSION = 1;
42
+ /**
43
+ * Location of the vendored contract, resolved relative to this module rather
44
+ * than to the process's working directory: the package runs as `npx
45
+ * fettle-mcp` from wherever the MCP client happens to spawn it. `src/` and
46
+ * `dist/` both sit one level under the package root, so the same relative
47
+ * path is correct compiled and under test.
48
+ */
49
+ export declare const CONTRACT_URL: import("url").URL;
50
+ /**
51
+ * Validate a parsed contract document.
52
+ *
53
+ * Structural only — the per-tool `input_schema` is handed to Ajv unexamined,
54
+ * which is the point: this package must not develop its own opinion about what
55
+ * a tool's arguments look like.
56
+ */
57
+ export declare function parseContract(raw: unknown): ToolContract;
58
+ /** Read and validate the vendored contract. */
59
+ export declare function loadContract(url?: URL): ToolContract;
60
+ /** Resolve a tool by name, or `undefined` when the contract has no such tool. */
61
+ export declare function findTool(contract: ToolContract, name: string): ToolEntry | undefined;
@@ -0,0 +1,113 @@
1
+ /**
2
+ * The tool contract: the one description both Fettle MCP channels interpret.
3
+ *
4
+ * The file this module loads is *generated* in the core backend
5
+ * (`scripts/generate_mcp_contract.py` from `src/mcp/registry.py`) and vendored
6
+ * here verbatim. Nothing in this package describes a tool by hand: adding a
7
+ * tool means re-vendoring the contract, not editing source. `pnpm
8
+ * contract:check` compares the vendored copy against what production serves at
9
+ * `GET /public-api/mcp-tools.json`, so drift is a failing check rather than a
10
+ * silent difference between the two channels.
11
+ */
12
+ import { readFileSync } from "node:fs";
13
+ /**
14
+ * The contract format version this code understands.
15
+ *
16
+ * Kept in lockstep with core's `SUPPORTED_CONTRACT_VERSION`. A guard, not a
17
+ * migration path — a future format bump surfaces as one legible message
18
+ * instead of an undefined field read at the first tool call.
19
+ */
20
+ export const SUPPORTED_CONTRACT_VERSION = 1;
21
+ /**
22
+ * Location of the vendored contract, resolved relative to this module rather
23
+ * than to the process's working directory: the package runs as `npx
24
+ * fettle-mcp` from wherever the MCP client happens to spawn it. `src/` and
25
+ * `dist/` both sit one level under the package root, so the same relative
26
+ * path is correct compiled and under test.
27
+ */
28
+ export const CONTRACT_URL = new URL("../contracts/mcp_tools.json", import.meta.url);
29
+ const METHODS = new Set(["GET", "POST", "PATCH", "DELETE"]);
30
+ function fail(message) {
31
+ throw new Error(`Invalid tool contract: ${message}`);
32
+ }
33
+ function assertOperation(operation, toolName) {
34
+ if (typeof operation !== "object" || operation === null) {
35
+ fail(`tool '${toolName}' has no operation object.`);
36
+ }
37
+ const candidate = operation;
38
+ if (typeof candidate.method !== "string" || !METHODS.has(candidate.method)) {
39
+ fail(`tool '${toolName}' has an unsupported method ${String(candidate.method)}.`);
40
+ }
41
+ if (typeof candidate.path !== "string" || !candidate.path.startsWith("/")) {
42
+ fail(`tool '${toolName}' has a non-absolute path.`);
43
+ }
44
+ const pathParams = candidate.path_params;
45
+ const query = candidate.query;
46
+ if (!Array.isArray(pathParams) || !Array.isArray(query)) {
47
+ fail(`tool '${toolName}' has malformed path_params/query.`);
48
+ }
49
+ return {
50
+ method: candidate.method,
51
+ path: candidate.path,
52
+ path_params: pathParams.map(String),
53
+ query: query.map(String),
54
+ body: candidate.body === true,
55
+ };
56
+ }
57
+ /**
58
+ * Validate a parsed contract document.
59
+ *
60
+ * Structural only — the per-tool `input_schema` is handed to Ajv unexamined,
61
+ * which is the point: this package must not develop its own opinion about what
62
+ * a tool's arguments look like.
63
+ */
64
+ export function parseContract(raw) {
65
+ if (typeof raw !== "object" || raw === null) {
66
+ fail("the document is not a JSON object.");
67
+ }
68
+ const document = raw;
69
+ if (document.version !== SUPPORTED_CONTRACT_VERSION) {
70
+ fail(`format version ${String(document.version)} is not the version ` +
71
+ `${SUPPORTED_CONTRACT_VERSION} this package understands. Upgrade ` +
72
+ `fettle-mcp (npm install -g fettle-mcp@latest) or pin an older one.`);
73
+ }
74
+ if (!Array.isArray(document.tools) || document.tools.length === 0) {
75
+ fail("the document carries no tools.");
76
+ }
77
+ const seen = new Set();
78
+ const tools = document.tools.map((entry) => {
79
+ if (typeof entry !== "object" || entry === null) {
80
+ fail("a tool entry is not an object.");
81
+ }
82
+ const candidate = entry;
83
+ const name = candidate.name;
84
+ if (typeof name !== "string" || name === "") {
85
+ fail("a tool entry has no name.");
86
+ }
87
+ if (seen.has(name)) {
88
+ fail(`tool '${name}' is declared twice.`);
89
+ }
90
+ seen.add(name);
91
+ if (typeof candidate.input_schema !== "object" || candidate.input_schema === null) {
92
+ fail(`tool '${name}' has no input_schema.`);
93
+ }
94
+ return {
95
+ name,
96
+ title: typeof candidate.title === "string" ? candidate.title : name,
97
+ description: typeof candidate.description === "string" ? candidate.description : "",
98
+ scope: candidate.scope === "write" ? "write" : "read",
99
+ input_schema: candidate.input_schema,
100
+ operation: assertOperation(candidate.operation, name),
101
+ };
102
+ });
103
+ return { version: document.version, tools };
104
+ }
105
+ /** Read and validate the vendored contract. */
106
+ export function loadContract(url = CONTRACT_URL) {
107
+ return parseContract(JSON.parse(readFileSync(url, "utf8")));
108
+ }
109
+ /** Resolve a tool by name, or `undefined` when the contract has no such tool. */
110
+ export function findTool(contract, name) {
111
+ return contract.tools.find((tool) => tool.name === name);
112
+ }
113
+ //# sourceMappingURL=contract.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"contract.js","sourceRoot":"","sources":["../src/contract.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Turn a failed API response into a sentence that says what to fix.
3
+ *
4
+ * The counterpart of core's `src/mcp/executor.py::describe_failure`, and
5
+ * deliberately a port by meaning rather than a shared implementation — the two
6
+ * channels differ in exactly one respect that matters to the reader. On the
7
+ * remote channel the credential arrives as an HTTP header the client set; here
8
+ * it comes from the `FETTLE_API_KEY` environment variable in the user's MCP
9
+ * client config, so that is the thing the model must tell its user to change.
10
+ *
11
+ * Every branch answers "what should the caller do differently", never just
12
+ * "what went wrong": a model that reads "rate limited" retries immediately, a
13
+ * model that reads "retry after 43 seconds" waits.
14
+ */
15
+ /** The API's two error body shapes, as core's exception handlers emit them. */
16
+ interface ErrorBody {
17
+ detail?: unknown;
18
+ message?: unknown;
19
+ retry_after?: unknown;
20
+ }
21
+ /** Describe a non-2xx response in terms of what the caller should change. */
22
+ export declare function describeFailure(statusCode: number, body: ErrorBody | null, headers?: Headers | null): string;
23
+ /**
24
+ * Describe a request that never produced a response at all.
25
+ *
26
+ * A thrown `fetch` is not something the model can fix by changing arguments,
27
+ * but it is very often something the *user* can fix — a typo in
28
+ * `FETTLE_API_URL`, a machine with no network, a corporate proxy. Saying which
29
+ * URL was attempted turns an opaque "fetch failed" into a diagnosis.
30
+ */
31
+ export declare function describeTransportFailure(baseUrl: string, cause: unknown): string;
32
+ export {};
package/dist/errors.js ADDED
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Turn a failed API response into a sentence that says what to fix.
3
+ *
4
+ * The counterpart of core's `src/mcp/executor.py::describe_failure`, and
5
+ * deliberately a port by meaning rather than a shared implementation — the two
6
+ * channels differ in exactly one respect that matters to the reader. On the
7
+ * remote channel the credential arrives as an HTTP header the client set; here
8
+ * it comes from the `FETTLE_API_KEY` environment variable in the user's MCP
9
+ * client config, so that is the thing the model must tell its user to change.
10
+ *
11
+ * Every branch answers "what should the caller do differently", never just
12
+ * "what went wrong": a model that reads "rate limited" retries immediately, a
13
+ * model that reads "retry after 43 seconds" waits.
14
+ */
15
+ /**
16
+ * The exact sentence core raises for an insufficient-scope 403
17
+ * (`PermissionDeniedException("API key scope is not sufficient")`). Matched
18
+ * verbatim rather than on the bare word "scope" so an unrelated 403 — a plan
19
+ * gate, say — whose message merely mentions scope in passing is never
20
+ * misdiagnosed as a key-scope problem.
21
+ */
22
+ const SCOPE_INSUFFICIENT_MESSAGE = "API key scope is not sufficient";
23
+ function extractMessage(body) {
24
+ if (body === null) {
25
+ return "";
26
+ }
27
+ if (typeof body.detail === "string") {
28
+ return body.detail;
29
+ }
30
+ if (typeof body.message === "string") {
31
+ return body.message;
32
+ }
33
+ return "";
34
+ }
35
+ /**
36
+ * Turn a FastAPI/pydantic `detail` list into a sentence naming each field.
37
+ *
38
+ * The `body`/`query`/`path` prefix pydantic puts at the head of every `loc` is
39
+ * dropped: it names the HTTP location, which is an implementation detail of a
40
+ * transport the model never sees. What is left is the argument name the model
41
+ * actually passed.
42
+ */
43
+ function describeValidationErrors(detail) {
44
+ const parts = [];
45
+ for (const error of detail) {
46
+ if (typeof error !== "object" || error === null) {
47
+ continue;
48
+ }
49
+ const candidate = error;
50
+ const loc = Array.isArray(candidate.loc) ? candidate.loc : [];
51
+ const fieldParts = loc
52
+ .filter((part) => part !== "body" && part !== "query" && part !== "path")
53
+ .map((part) => String(part));
54
+ const field = fieldParts.length > 0 ? fieldParts.join(".") : "request";
55
+ const msg = typeof candidate.msg === "string" ? candidate.msg : "is invalid";
56
+ parts.push(`'${field}': ${msg}`);
57
+ }
58
+ if (parts.length === 0) {
59
+ return "The arguments failed validation. Check the argument values and retry.";
60
+ }
61
+ return `Invalid argument(s) — ${parts.join("; ")}. Fix the value(s) and retry.`;
62
+ }
63
+ /**
64
+ * Read the retry delay a 429 carries.
65
+ *
66
+ * Core answers rate limits with `retry_after` in the JSON body; the
67
+ * `Retry-After` header carries the same number and is read as a fallback,
68
+ * because unlike the remote channel this package also faces whatever sits in
69
+ * front of the API — a proxy that rate-limits before the application does
70
+ * answers with the header and no body of ours at all.
71
+ */
72
+ function retryAfterSeconds(body, headers) {
73
+ const fromBody = body?.retry_after;
74
+ if (typeof fromBody === "number" && Number.isFinite(fromBody)) {
75
+ return fromBody;
76
+ }
77
+ const fromHeader = headers?.get("retry-after");
78
+ if (fromHeader !== null && fromHeader !== undefined) {
79
+ const parsed = Number.parseInt(fromHeader, 10);
80
+ if (Number.isFinite(parsed)) {
81
+ return parsed;
82
+ }
83
+ }
84
+ return null;
85
+ }
86
+ /** Describe a non-2xx response in terms of what the caller should change. */
87
+ export function describeFailure(statusCode, body, headers = null) {
88
+ const message = extractMessage(body);
89
+ if (statusCode === 401) {
90
+ return ("Authentication failed: Fettle rejected the API key. Check that " +
91
+ "FETTLE_API_KEY in the MCP client configuration holds a current, " +
92
+ "un-revoked key from Settings → API Keys at https://fettle.sh, then " +
93
+ "restart the MCP client so it picks the new value up.");
94
+ }
95
+ if (statusCode === 403) {
96
+ if (message.includes(SCOPE_INSUFFICIENT_MESSAGE)) {
97
+ return ("This tool needs an API key with write scope; FETTLE_API_KEY holds a " +
98
+ "read-only key. Create a write-scoped key in Settings → API Keys at " +
99
+ "https://fettle.sh and restart the MCP client with it.");
100
+ }
101
+ return `Forbidden: ${message || "the API key is not permitted to perform this action"}.`;
102
+ }
103
+ if (statusCode === 404) {
104
+ return (`Not found: ${message || "the requested resource does not exist"}. ` +
105
+ "Check the id and try again — list_monitors, list_incidents, " +
106
+ "list_heartbeats and list_status_pages return the current ids.");
107
+ }
108
+ if (statusCode === 422) {
109
+ const detail = body?.detail;
110
+ if (Array.isArray(detail)) {
111
+ return describeValidationErrors(detail);
112
+ }
113
+ return (`Invalid request: ${message || "the arguments failed validation"}. ` +
114
+ "Check the argument values and retry.");
115
+ }
116
+ if (statusCode === 429) {
117
+ const retryAfter = retryAfterSeconds(body, headers);
118
+ if (retryAfter !== null) {
119
+ return (`Rate limited by the plan's request budget: retry after ${retryAfter} ` +
120
+ "seconds, or upgrade the plan at https://fettle.sh/pricing.");
121
+ }
122
+ return "Rate limited by the plan's request budget: slow down and retry shortly.";
123
+ }
124
+ return (`Request failed with status ${statusCode}: ` +
125
+ `${message || "no further detail was provided"}.`);
126
+ }
127
+ /**
128
+ * Describe a request that never produced a response at all.
129
+ *
130
+ * A thrown `fetch` is not something the model can fix by changing arguments,
131
+ * but it is very often something the *user* can fix — a typo in
132
+ * `FETTLE_API_URL`, a machine with no network, a corporate proxy. Saying which
133
+ * URL was attempted turns an opaque "fetch failed" into a diagnosis.
134
+ */
135
+ export function describeTransportFailure(baseUrl, cause) {
136
+ const reason = cause instanceof Error ? cause.message : String(cause);
137
+ return (`Could not reach the Fettle API at ${baseUrl}: ${reason}. Check network ` +
138
+ "connectivity and the FETTLE_API_URL setting (it defaults to " +
139
+ "https://api.fettle.sh and should not normally be set).");
140
+ }
141
+ //# sourceMappingURL=errors.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.js","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Execute a tool call as a real HTTP request against the public API.
3
+ *
4
+ * The central decision of S2, held identically in both channels: an MCP tool
5
+ * call is a transport adapter, not a second implementation of the API. This
6
+ * module maps a `ToolEntry` plus the model's arguments onto an ordinary
7
+ * `/v1/...` request carrying the user's key as a Bearer token. Authentication,
8
+ * the `x-fettle-public-api` scope marker, the per-organization rate bucket,
9
+ * pydantic validation and pagination all execute in core, unchanged, because
10
+ * it is a real request hitting the real route.
11
+ *
12
+ * Where core's executor dispatches in-process through `httpx.ASGITransport`,
13
+ * this one goes over the network with `fetch` — but the argument-routing rules
14
+ * and the success/failure boundary are deliberately the same, so a tool
15
+ * behaves identically whichever channel a user picked.
16
+ */
17
+ import type { ToolEntry } from "./contract.js";
18
+ /**
19
+ * The MCP `tools/call` result shape.
20
+ *
21
+ * The index signature is what MCP's own result type requires — the protocol
22
+ * lets a result carry `_meta` and forward-compatible extras alongside the
23
+ * fields this package sets.
24
+ */
25
+ export interface ToolResult {
26
+ content: Array<{
27
+ type: "text";
28
+ text: string;
29
+ }>;
30
+ isError: boolean;
31
+ [key: string]: unknown;
32
+ }
33
+ /**
34
+ * An argument the model can fix: missing, wrongly typed, or aimed at a
35
+ * parameter this tool does not declare. Surfaces as an error *tool result*, so
36
+ * the model reads the explanation and retries.
37
+ */
38
+ export declare class ToolArgumentError extends Error {
39
+ }
40
+ /**
41
+ * A failure the model cannot fix by retrying with different arguments — the
42
+ * API answered 5xx, or the request never got there. Surfaces as a JSON-RPC
43
+ * error, the same choice core makes for a 5xx.
44
+ */
45
+ export declare class ToolProtocolError extends Error {
46
+ }
47
+ export interface ExecutorOptions {
48
+ apiUrl: string;
49
+ apiKey: string;
50
+ timeoutMs: number;
51
+ userAgent: string;
52
+ /** Injected in tests; defaults to the global `fetch`. */
53
+ fetchImpl?: typeof fetch;
54
+ }
55
+ /** Dispatches `tools/call` invocations as requests against the public API. */
56
+ export declare class ToolExecutor {
57
+ private readonly options;
58
+ private readonly fetchImpl;
59
+ constructor(options: ExecutorOptions);
60
+ /**
61
+ * Execute one tool call and return an MCP `tools/call` result.
62
+ *
63
+ * Flow:
64
+ * 1. Substitute the operation's declared path parameters into the URL
65
+ * template, percent-encoding each value so it cannot introduce extra path
66
+ * segments or a query string of its own.
67
+ * 2. Route the declared query parameters into the query string.
68
+ * 3. Route whatever arguments remain into the JSON body when the operation
69
+ * declares one; otherwise an argument that matched no declared parameter
70
+ * is rejected rather than silently dropped.
71
+ * 4. Dispatch the request with the user's key as a Bearer token, following
72
+ * no redirects: a 3xx is reported, never chased, so the credential cannot
73
+ * be replayed to a host the contract did not name.
74
+ * 5. Map the response — 2xx is a result, any other non-5xx is an actionable
75
+ * tool error, 5xx and transport failures raise.
76
+ *
77
+ * Throws:
78
+ * ToolArgumentError: an argument is missing, wrongly typed, or matched no
79
+ * declared parameter on a tool with no request body.
80
+ * ToolProtocolError: the API answered 5xx, or the request never completed.
81
+ */
82
+ execute(tool: ToolEntry, args: Record<string, unknown>): Promise<ToolResult>;
83
+ }