@affset/mcp 0.1.0 → 0.2.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/.env.example +5 -0
- package/README.md +158 -37
- package/dist/client.d.ts +43 -0
- package/dist/client.js +15 -2
- package/dist/config.d.ts +7 -0
- package/dist/config.js +15 -54
- package/dist/core.d.ts +18 -0
- package/dist/core.js +18 -0
- package/dist/docs.d.ts +39 -0
- package/dist/docs.js +105 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +0 -0
- package/dist/lib/format.d.ts +46 -0
- package/dist/lib/format.js +3 -0
- package/dist/lib/integrationUrls.d.ts +57 -0
- package/dist/lib/linkArgs.d.ts +27 -0
- package/dist/lib/patch.d.ts +10 -0
- package/dist/lib/payoutRules.d.ts +38 -0
- package/dist/lib/readBody.d.ts +17 -0
- package/dist/lib/readBody.js +69 -0
- package/dist/lib/targeting.d.ts +51 -0
- package/dist/lib/time.d.ts +38 -0
- package/dist/lib/toolResult.d.ts +7 -0
- package/dist/lib/urls.d.ts +2 -0
- package/dist/lib/zones.d.ts +24 -0
- package/dist/registerTools.d.ts +40 -0
- package/dist/registerTools.js +401 -0
- package/dist/runtimeConfig.d.ts +37 -0
- package/dist/runtimeConfig.js +93 -0
- package/dist/server.d.ts +4 -0
- package/dist/server.js +3 -306
- package/dist/tools/createCampaign.d.ts +25 -0
- package/dist/tools/createCampaign.js +3 -2
- package/dist/tools/createTeamMember.d.ts +26 -0
- package/dist/tools/createTeamMember.js +204 -0
- package/dist/tools/createZone.d.ts +22 -0
- package/dist/tools/cutZones.d.ts +29 -0
- package/dist/tools/deletePayoutRule.d.ts +16 -0
- package/dist/tools/getCampaign.d.ts +12 -0
- package/dist/tools/getCampaign.js +150 -0
- package/dist/tools/getStats.d.ts +40 -0
- package/dist/tools/getStats.js +37 -2
- package/dist/tools/getTrackingLink.d.ts +23 -0
- package/dist/tools/getZoneUrl.d.ts +21 -0
- package/dist/tools/listCampaigns.d.ts +23 -0
- package/dist/tools/listConversions.d.ts +29 -0
- package/dist/tools/listPayoutRules.d.ts +12 -0
- package/dist/tools/listSubLabels.d.ts +5 -0
- package/dist/tools/listTargetingRules.d.ts +12 -0
- package/dist/tools/listTargetingTypes.d.ts +5 -0
- package/dist/tools/listTeam.d.ts +14 -0
- package/dist/tools/listZones.d.ts +23 -0
- package/dist/tools/removeTargetingRule.d.ts +21 -0
- package/dist/tools/setCampaignStatus.d.ts +18 -0
- package/dist/tools/setPayoutGoal.d.ts +16 -0
- package/dist/tools/setPayoutRule.d.ts +18 -0
- package/dist/tools/setSubLabels.d.ts +22 -0
- package/dist/tools/setTargetingRule.d.ts +21 -0
- package/dist/tools/updateCampaign.d.ts +35 -0
- package/dist/tools/updateZone.d.ts +25 -0
- package/dist/tools/whoami.d.ts +6 -0
- package/dist/types.d.ts +210 -0
- package/dist/types.js +1 -0
- package/dist/version.d.ts +7 -0
- package/dist/version.js +8 -0
- package/package.json +16 -2
package/dist/docs.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { readResponseText, ResponseTooLargeError } from "./lib/readBody.js";
|
|
2
|
+
import { normalizeRuntimeConfig } from "./runtimeConfig.js";
|
|
3
|
+
export const DOCS_FEEDS = {
|
|
4
|
+
markdown: { file: "api-reference.md", mimeType: "text/markdown" },
|
|
5
|
+
json: { file: "api-reference.json", mimeType: "application/json" },
|
|
6
|
+
};
|
|
7
|
+
/** A hostile or misconfigured origin shouldn't be able to flood model context. */
|
|
8
|
+
const MAX_DOCS_BYTES = 2_000_000;
|
|
9
|
+
/** Thrown when a docs feed can't be fetched or looks wrong. */
|
|
10
|
+
export class DocsFetchError extends Error {
|
|
11
|
+
constructor(message) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = "DocsFetchError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
async function readDocsBody(res, url) {
|
|
17
|
+
try {
|
|
18
|
+
return await readResponseText(res, MAX_DOCS_BYTES);
|
|
19
|
+
}
|
|
20
|
+
catch (err) {
|
|
21
|
+
if (err instanceof ResponseTooLargeError) {
|
|
22
|
+
throw new DocsFetchError(err.kind === "declared"
|
|
23
|
+
? `Docs response from ${url} declares Content-Length ${err.size}, over the ${MAX_DOCS_BYTES}-byte limit.`
|
|
24
|
+
: `Docs response from ${url} exceeded the ${MAX_DOCS_BYTES}-byte limit while streaming.`);
|
|
25
|
+
}
|
|
26
|
+
throw err;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** SPA catch-alls often return 200 HTML for missing static feeds — refuse those. */
|
|
30
|
+
function assertNotHtmlShell(text, contentType, url) {
|
|
31
|
+
const type = (contentType ?? "").toLowerCase();
|
|
32
|
+
if (type.includes("text/html") || type.includes("application/xhtml")) {
|
|
33
|
+
throw new DocsFetchError(`Docs fetch for ${url} returned HTML (${contentType ?? "unknown type"}) instead of the documentation feed. ` +
|
|
34
|
+
`Is the feed deployed at that origin?`);
|
|
35
|
+
}
|
|
36
|
+
const head = text.slice(0, 256).trimStart().toLowerCase();
|
|
37
|
+
if (head.startsWith("<!doctype html") || head.startsWith("<html")) {
|
|
38
|
+
throw new DocsFetchError(`Docs fetch for ${url} returned an HTML document instead of the documentation feed. ` +
|
|
39
|
+
`Is the feed deployed at that origin?`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function validateFeedBody(text, feed, url) {
|
|
43
|
+
if (text.length === 0) {
|
|
44
|
+
throw new DocsFetchError(`Docs response from ${url} was empty.`);
|
|
45
|
+
}
|
|
46
|
+
if (feed.mimeType === "application/json") {
|
|
47
|
+
let parsed;
|
|
48
|
+
try {
|
|
49
|
+
parsed = JSON.parse(text);
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
throw new DocsFetchError(`Docs response from ${url} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
53
|
+
}
|
|
54
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
55
|
+
throw new DocsFetchError(`Docs JSON from ${url} must be a top-level object.`);
|
|
56
|
+
}
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
// Markdown feed: require a heading so we don't accept arbitrary plain text.
|
|
60
|
+
if (!/^#\s+\S/m.test(text.slice(0, 4_096))) {
|
|
61
|
+
throw new DocsFetchError(`Docs Markdown from ${url} does not look like the API reference (missing a top-level heading).`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Fetch one docs feed and return its text. Throws {@link DocsFetchError} with an
|
|
66
|
+
* actionable message on any network error, non-2xx status, HTML SPA fallback,
|
|
67
|
+
* invalid body, or oversized response — the MCP layer surfaces that to the
|
|
68
|
+
* caller as the resource read failure.
|
|
69
|
+
*/
|
|
70
|
+
export async function fetchDocsFeed(config, feed) {
|
|
71
|
+
const runtimeConfig = normalizeRuntimeConfig(config);
|
|
72
|
+
const url = `${runtimeConfig.docsBaseUrl}/${feed.file}`;
|
|
73
|
+
let res;
|
|
74
|
+
try {
|
|
75
|
+
res = await fetch(url, {
|
|
76
|
+
// No Authorization / X-Namespace: the docs are public and this is a
|
|
77
|
+
// different origin from the tenant API. Accept nudges the CDN toward the
|
|
78
|
+
// right representation without depending on it.
|
|
79
|
+
headers: { Accept: feed.mimeType },
|
|
80
|
+
// Don't follow redirects to a different host — a misconfigured docs
|
|
81
|
+
// origin shouldn't silently pull content from elsewhere.
|
|
82
|
+
redirect: "manual",
|
|
83
|
+
signal: AbortSignal.timeout(runtimeConfig.requestTimeoutMs),
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
if (err instanceof Error && err.name === "TimeoutError") {
|
|
88
|
+
throw new DocsFetchError(`Timed out after ${runtimeConfig.requestTimeoutMs}ms fetching docs from ${url}`);
|
|
89
|
+
}
|
|
90
|
+
throw new DocsFetchError(`Could not reach docs at ${url}: ${err instanceof Error ? err.message : String(err)}`);
|
|
91
|
+
}
|
|
92
|
+
if (res.status >= 300 && res.status < 400) {
|
|
93
|
+
const location = res.headers.get("location") ?? "(no Location header)";
|
|
94
|
+
throw new DocsFetchError(`Docs fetch for ${url} returned HTTP ${res.status} redirect to ${location}. ` +
|
|
95
|
+
`AFFSET_DOCS_URL must point at the origin that serves the feeds directly.`);
|
|
96
|
+
}
|
|
97
|
+
if (!res.ok) {
|
|
98
|
+
throw new DocsFetchError(`Docs fetch for ${url} returned HTTP ${res.status}.`);
|
|
99
|
+
}
|
|
100
|
+
const text = await readDocsBody(res, url);
|
|
101
|
+
assertNotHtmlShell(text, res.headers.get("content-type"), url);
|
|
102
|
+
validateFeedBody(text, feed, url);
|
|
103
|
+
return text;
|
|
104
|
+
}
|
|
105
|
+
//# sourceMappingURL=docs.js.map
|
package/dist/index.d.ts
ADDED
package/dist/index.js
CHANGED
|
File without changes
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type GroupBy, type StatRow, type SubLabels } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Escape a value for a Markdown table cell. Pipes/newlines break table layout;
|
|
4
|
+
* backticks and brackets are escaped too since these cells often carry
|
|
5
|
+
* traffic-source-controlled strings (zone names, sub values) rendered straight
|
|
6
|
+
* into the model's context — unescaped backticks let that data break out of its
|
|
7
|
+
* cell and forge what reads like a fenced code block or inline instruction.
|
|
8
|
+
*/
|
|
9
|
+
export declare function mdCell(value: string): string;
|
|
10
|
+
/**
|
|
11
|
+
* Truncate a value that came from outside the tenant's own dashboard — sub values,
|
|
12
|
+
* zone/campaign names on rows the API returned, conversion payloads — before it
|
|
13
|
+
* reaches the model's context. These fields round-trip through public, unauthenticated
|
|
14
|
+
* endpoints (a click, a conversion pixel), so nothing bounds their length or content
|
|
15
|
+
* on the way in; without a cap here, one long field could bury an injected
|
|
16
|
+
* instruction past whatever a client renders or a reviewer reads.
|
|
17
|
+
*/
|
|
18
|
+
export declare function capUntrusted(value: string, max?: number): string;
|
|
19
|
+
/** Format a number as USD, e.g. 1234.5 -> "$1,234.50". */
|
|
20
|
+
export declare function money(n: number | undefined | null): string;
|
|
21
|
+
/**
|
|
22
|
+
* Format a USD amount that can be smaller than a cent. Payout rules accept down
|
|
23
|
+
* to $0.00001 and push/mVAS payouts really do sit there, where `money()`'s two
|
|
24
|
+
* decimals would render $0.005 as "$0.00" — a preview the operator would confirm
|
|
25
|
+
* believing it said something else. Ordinary amounts still show two decimals.
|
|
26
|
+
*/
|
|
27
|
+
export declare function moneyPrecise(n: number | undefined | null): string;
|
|
28
|
+
/** Format a fraction as a percentage, e.g. 0.0145 -> "1.45%". */
|
|
29
|
+
export declare function pct(fraction: number): string;
|
|
30
|
+
/** Format ROI (a fraction) with sign, e.g. 0.35 -> "+35%". Null -> "—". */
|
|
31
|
+
export declare function roi(fraction: number | null | undefined): string;
|
|
32
|
+
/** Conversion rate for a row (conversions / clicks), 0 when no clicks. */
|
|
33
|
+
export declare function conversionRate(row: StatRow): number;
|
|
34
|
+
/** The human label for a row given the grouping dimension. */
|
|
35
|
+
export declare function rowLabel(row: StatRow, groupBy: GroupBy): string;
|
|
36
|
+
/**
|
|
37
|
+
* Column title for a grouping: for sub1..sub5 the tenant's label (from the stats
|
|
38
|
+
* response `sub_labels`) with the raw key appended for addressability —
|
|
39
|
+
* "Zone (sub1)" — since filters/group_by still take the raw key. Raw key otherwise.
|
|
40
|
+
*/
|
|
41
|
+
export declare function groupHeader(groupBy: GroupBy, subLabels?: SubLabels): string;
|
|
42
|
+
/**
|
|
43
|
+
* Render grouped stats as a Markdown table with a totals row. CR is derived
|
|
44
|
+
* client-side; ROI comes from the API (blank until media_cost is populated).
|
|
45
|
+
*/
|
|
46
|
+
export declare function formatStatsTable(rows: StatRow[], groupBy: GroupBy, subLabels?: SubLabels): string;
|
package/dist/lib/format.js
CHANGED
|
@@ -77,6 +77,8 @@ export function rowLabel(row, groupBy) {
|
|
|
77
77
|
return capUntrusted(row.conversion_type || "(none)");
|
|
78
78
|
case "publisher_email":
|
|
79
79
|
return row.publisher_email || "(none)";
|
|
80
|
+
case "advertiser_email":
|
|
81
|
+
return row.advertiser_email || "(none)";
|
|
80
82
|
default:
|
|
81
83
|
// sub1..sub5 — attributed from click/pixel query params, same trust level.
|
|
82
84
|
return capUntrusted(row[groupBy] || "(none)");
|
|
@@ -89,6 +91,7 @@ const HEADER_BY_GROUP = {
|
|
|
89
91
|
country: "Country",
|
|
90
92
|
conversion_type: "Conv. type",
|
|
91
93
|
publisher_email: "Publisher",
|
|
94
|
+
advertiser_email: "Advertiser",
|
|
92
95
|
sub1: "sub1",
|
|
93
96
|
sub2: "sub2",
|
|
94
97
|
sub3: "sub3",
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two URLs a media buyer hands to a traffic source, and the base they hang off.
|
|
3
|
+
*
|
|
4
|
+
* - **Zone URL** — `/serve/{zone_id}`. The network sends traffic here and affset
|
|
5
|
+
* picks a campaign out of the zone's rotation (active campaigns only).
|
|
6
|
+
* - **Tracking link** — `/track/click/{campaign_id}/{zone_id}`. Straight to one
|
|
7
|
+
* campaign, no rotation and no targeting checks. The campaign and zone still have
|
|
8
|
+
* to be active because the public tracking path reads the active-serving cache.
|
|
9
|
+
*
|
|
10
|
+
* Both carry the same query convention: `source_click_id` (the network's own click
|
|
11
|
+
* token, echoed back on postback) plus the five analytics sub slots.
|
|
12
|
+
*/
|
|
13
|
+
import type { AffsetClient } from "../client.js";
|
|
14
|
+
import type { Config } from "../runtimeConfig.js";
|
|
15
|
+
import { type SubKey, type SubLabels } from "../types.js";
|
|
16
|
+
/** Query parameter carrying the traffic source's click token. */
|
|
17
|
+
export declare const SOURCE_CLICK_ID_PARAM = "source_click_id";
|
|
18
|
+
/**
|
|
19
|
+
* Default click-token macro. RichAds' spelling — the pilot source; other networks
|
|
20
|
+
* substitute their own (`[CLICK_ID]`, `${SUBID}`, …).
|
|
21
|
+
*/
|
|
22
|
+
export declare const DEFAULT_SOURCE_CLICK_ID = "{clickid}";
|
|
23
|
+
export type SubValues = Partial<Record<SubKey, string>>;
|
|
24
|
+
/** Everything needed to build integration URLs, from a single `/api/tenant` read. */
|
|
25
|
+
export interface TenantIntegration {
|
|
26
|
+
/** Origin the network will actually be pointed at (custom API domain when set). */
|
|
27
|
+
baseUrl: string;
|
|
28
|
+
subLabels: SubLabels;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Resolve the origin for URLs that leave the building. A tenant with a custom API
|
|
32
|
+
* domain must see that domain: these URLs get pasted into a network's campaign
|
|
33
|
+
* settings, so they have to be the final ones.
|
|
34
|
+
*
|
|
35
|
+
* Never fails — the links matter more than the branding, so a bad or unreadable
|
|
36
|
+
* setting falls back to the configured API base.
|
|
37
|
+
*/
|
|
38
|
+
export declare function fetchTenantIntegration(client: AffsetClient, config: Config): Promise<TenantIntegration>;
|
|
39
|
+
/** `custom_api_domain` is tenant-editable free text — only use it if it parses. */
|
|
40
|
+
export declare function integrationBaseUrl(customApiDomain: string | null | undefined, fallback: string): string;
|
|
41
|
+
export interface LinkParams {
|
|
42
|
+
/** Click-token macro. Omit for the RichAds default; pass "" to leave it out. */
|
|
43
|
+
sourceClickId?: string;
|
|
44
|
+
/** Explicit sub values; unset slots fall back to a label-derived placeholder. */
|
|
45
|
+
subs?: SubValues;
|
|
46
|
+
/** Network cost macro for `?cost=`. Omitted when absent. */
|
|
47
|
+
cost?: string;
|
|
48
|
+
subLabels?: SubLabels;
|
|
49
|
+
}
|
|
50
|
+
/** `/serve/{zone_id}` — the zone URL, for campaign rotation. */
|
|
51
|
+
export declare function buildZoneUrl(baseUrl: string, zoneId: string, params?: LinkParams): string;
|
|
52
|
+
/** `/track/click/{campaign_id}/{zone_id}` — straight to one campaign. */
|
|
53
|
+
export declare function buildTrackingLink(baseUrl: string, campaignId: number | string, zoneId: string, params?: LinkParams): string;
|
|
54
|
+
/** "Creative name" -> `creative_name`; falls back to the raw sub key. */
|
|
55
|
+
export declare function placeholderName(key: string, label: string | undefined): string;
|
|
56
|
+
/** "sub1 = Creative, sub2 = Placement" — so the buyer can see what belongs where. */
|
|
57
|
+
export declare function subLegend(subLabels: SubLabels, subs?: SubValues): string | null;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Input shape shared by the two integration-URL tools. Both hand a URL to a traffic
|
|
3
|
+
* source, so both take the same knobs: which click-token macro to use, what to put
|
|
4
|
+
* in the five sub slots, and whether to import the network's cost macro.
|
|
5
|
+
*/
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { type SubValues } from "./integrationUrls.js";
|
|
8
|
+
export declare const linkInputSchema: {
|
|
9
|
+
source_click_id: z.ZodOptional<z.ZodEffects<z.ZodString, string, string>>;
|
|
10
|
+
cost: z.ZodOptional<z.ZodEffects<z.ZodString, string, string>>;
|
|
11
|
+
sub1: z.ZodOptional<z.ZodEffects<z.ZodString, string, string>>;
|
|
12
|
+
sub2: z.ZodOptional<z.ZodEffects<z.ZodString, string, string>>;
|
|
13
|
+
sub3: z.ZodOptional<z.ZodEffects<z.ZodString, string, string>>;
|
|
14
|
+
sub4: z.ZodOptional<z.ZodEffects<z.ZodString, string, string>>;
|
|
15
|
+
sub5: z.ZodOptional<z.ZodEffects<z.ZodString, string, string>>;
|
|
16
|
+
};
|
|
17
|
+
export type LinkArgs = {
|
|
18
|
+
source_click_id?: string;
|
|
19
|
+
cost?: string;
|
|
20
|
+
sub1?: string;
|
|
21
|
+
sub2?: string;
|
|
22
|
+
sub3?: string;
|
|
23
|
+
sub4?: string;
|
|
24
|
+
sub5?: string;
|
|
25
|
+
};
|
|
26
|
+
/** Pull the flat sub1..sub5 args into the record the URL builders take. */
|
|
27
|
+
export declare function collectSubs(args: LinkArgs): SubValues;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** One field change for dry-run / apply summaries. */
|
|
2
|
+
export interface FieldChange {
|
|
3
|
+
field: string;
|
|
4
|
+
from: string;
|
|
5
|
+
to: string;
|
|
6
|
+
}
|
|
7
|
+
/** Render a before → after markdown table. */
|
|
8
|
+
export declare function renderDiff(changes: FieldChange[]): string;
|
|
9
|
+
/** Display helper for nullable / missing values. */
|
|
10
|
+
export declare function displayValue(value: unknown): string;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Payout-rule helpers shared by the payout tools.
|
|
3
|
+
*
|
|
4
|
+
* The API has no update for a payout rule: the (campaign, zone) pair is unique,
|
|
5
|
+
* so a create over an existing scope returns 409 and changing a payout means
|
|
6
|
+
* delete-then-create. That leaves a window where the campaign has no rule at all,
|
|
7
|
+
* and a campaign with no matching rule resolves every conversion to $0 — silently,
|
|
8
|
+
* because a missing payout is a normal state rather than an error. `replacePayout`
|
|
9
|
+
* exists so that window is always either closed or reported.
|
|
10
|
+
*/
|
|
11
|
+
import { type AffsetClient } from "../client.js";
|
|
12
|
+
import type { PayoutRule } from "../types.js";
|
|
13
|
+
export declare function fetchPayoutRules(client: AffsetClient, campaignId: string): Promise<PayoutRule[]>;
|
|
14
|
+
/** The rule governing one scope: `null` zoneId is the campaign-wide rule. */
|
|
15
|
+
export declare function findPayoutRule(rules: PayoutRule[], zoneId: string | null): PayoutRule | undefined;
|
|
16
|
+
/** Delete the rule governing one scope (`null` zoneId = the campaign-wide rule). */
|
|
17
|
+
export declare function deletePayoutScope(client: AffsetClient, campaignId: string, zoneId: string | null): Promise<void>;
|
|
18
|
+
export declare function createPayoutRule(client: AffsetClient, campaignId: string, zoneId: string | null, payout: number): Promise<PayoutRule>;
|
|
19
|
+
export type ReplaceResult = {
|
|
20
|
+
rule: PayoutRule;
|
|
21
|
+
}
|
|
22
|
+
/** Create failed; the previous payout was put back, so nothing was lost. */
|
|
23
|
+
| {
|
|
24
|
+
rolledBack: PayoutRule;
|
|
25
|
+
cause: unknown;
|
|
26
|
+
}
|
|
27
|
+
/** Create failed and the rollback failed too — the scope now has no rule. */
|
|
28
|
+
| {
|
|
29
|
+
lost: PayoutRule;
|
|
30
|
+
cause: unknown;
|
|
31
|
+
rollbackCause: unknown;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Replace the payout for one scope. On a failed create the previous rule is
|
|
35
|
+
* restored, so a network blip or a rejected value cannot leave the campaign
|
|
36
|
+
* paying $0 on every conversion.
|
|
37
|
+
*/
|
|
38
|
+
export declare function replacePayout(client: AffsetClient, campaignId: string, zoneId: string | null, existing: PayoutRule, payout: number): Promise<ReplaceResult>;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bound upstream memory use before parsing or rendering into model context.
|
|
3
|
+
* Runtime-agnostic (no `node:` imports) so the tenant API client and docs
|
|
4
|
+
* fetcher can share it on Workers.
|
|
5
|
+
*/
|
|
6
|
+
export declare class ResponseTooLargeError extends Error {
|
|
7
|
+
readonly kind: "declared" | "streaming";
|
|
8
|
+
readonly limit: number;
|
|
9
|
+
readonly size?: number | undefined;
|
|
10
|
+
readonly name = "ResponseTooLargeError";
|
|
11
|
+
constructor(kind: "declared" | "streaming", limit: number, size?: number | undefined);
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Read a response body as UTF-8, aborting once it exceeds `maxBytes`. Prefers
|
|
15
|
+
* Content-Length when present so oversized bodies never enter memory.
|
|
16
|
+
*/
|
|
17
|
+
export declare function readResponseText(res: Response, maxBytes: number): Promise<string>;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bound upstream memory use before parsing or rendering into model context.
|
|
3
|
+
* Runtime-agnostic (no `node:` imports) so the tenant API client and docs
|
|
4
|
+
* fetcher can share it on Workers.
|
|
5
|
+
*/
|
|
6
|
+
export class ResponseTooLargeError extends Error {
|
|
7
|
+
kind;
|
|
8
|
+
limit;
|
|
9
|
+
size;
|
|
10
|
+
name = "ResponseTooLargeError";
|
|
11
|
+
constructor(kind, limit, size) {
|
|
12
|
+
super(kind === "declared"
|
|
13
|
+
? `declared ${size} bytes (limit ${limit})`
|
|
14
|
+
: `exceeded ${limit} bytes while streaming`);
|
|
15
|
+
this.kind = kind;
|
|
16
|
+
this.limit = limit;
|
|
17
|
+
this.size = size;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Read a response body as UTF-8, aborting once it exceeds `maxBytes`. Prefers
|
|
22
|
+
* Content-Length when present so oversized bodies never enter memory.
|
|
23
|
+
*/
|
|
24
|
+
export async function readResponseText(res, maxBytes) {
|
|
25
|
+
const contentLength = res.headers.get("content-length");
|
|
26
|
+
if (contentLength !== null) {
|
|
27
|
+
const declared = Number(contentLength);
|
|
28
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
29
|
+
if (res.body)
|
|
30
|
+
await res.body.cancel().catch(() => undefined);
|
|
31
|
+
throw new ResponseTooLargeError("declared", maxBytes, declared);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (!res.body)
|
|
35
|
+
return "";
|
|
36
|
+
const reader = res.body.getReader();
|
|
37
|
+
const chunks = [];
|
|
38
|
+
let total = 0;
|
|
39
|
+
try {
|
|
40
|
+
for (;;) {
|
|
41
|
+
const { done, value } = await reader.read();
|
|
42
|
+
if (done)
|
|
43
|
+
break;
|
|
44
|
+
total += value.byteLength;
|
|
45
|
+
if (total > maxBytes) {
|
|
46
|
+
await reader.cancel().catch(() => undefined);
|
|
47
|
+
throw new ResponseTooLargeError("streaming", maxBytes, total);
|
|
48
|
+
}
|
|
49
|
+
chunks.push(value);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
try {
|
|
54
|
+
reader.releaseLock();
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// cancel() already released the lock; a throw here would mask the
|
|
58
|
+
// size-limit error the caller needs to surface.
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const body = new Uint8Array(total);
|
|
62
|
+
let offset = 0;
|
|
63
|
+
for (const chunk of chunks) {
|
|
64
|
+
body.set(chunk, offset);
|
|
65
|
+
offset += chunk.byteLength;
|
|
66
|
+
}
|
|
67
|
+
return new TextDecoder().decode(body);
|
|
68
|
+
}
|
|
69
|
+
//# sourceMappingURL=readBody.js.map
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Targeting helpers shared by the targeting tools.
|
|
3
|
+
*
|
|
4
|
+
* The serve path matches `targeting_rules.rule` with an exact, case-sensitive
|
|
5
|
+
* string compare against what Cloudflare and Bowser produce for the request
|
|
6
|
+
* (lite-adserver `campaignSelectionService`): `CF-IPCountry` is upper-case
|
|
7
|
+
* ISO-3166 alpha-2, OS/browser names are Bowser's own spellings, device type is
|
|
8
|
+
* one of three lower-case words. A rule an operator reads as obviously correct —
|
|
9
|
+
* `geo: br,mx`, `os: android` — therefore matches nothing, and a whitelist that
|
|
10
|
+
* matches nothing takes the campaign out of rotation without an error anywhere.
|
|
11
|
+
* Everything written through these tools is normalised to the serve path's
|
|
12
|
+
* spelling first, and anything unrecognised comes back as a warning rather than
|
|
13
|
+
* being silently stored.
|
|
14
|
+
*/
|
|
15
|
+
import type { AffsetClient } from "../client.js";
|
|
16
|
+
import type { TargetingRule, TargetingRuleType } from "../types.js";
|
|
17
|
+
export type TargetingMethod = "whitelist" | "blacklist";
|
|
18
|
+
/**
|
|
19
|
+
* Seeded types the `/serve` path never evaluates. They accept writes through the
|
|
20
|
+
* API and then do nothing, so an hours rule reads as a working dayparting setup
|
|
21
|
+
* while the campaign keeps buying around the clock.
|
|
22
|
+
*/
|
|
23
|
+
export declare const UNENFORCED_TYPES: Record<string, string>;
|
|
24
|
+
export declare function fetchTargetingTypes(client: AffsetClient): Promise<TargetingRuleType[]>;
|
|
25
|
+
export declare function fetchTargetingRules(client: AffsetClient, campaignId: string): Promise<TargetingRule[]>;
|
|
26
|
+
/**
|
|
27
|
+
* Write the campaign's full rule set. The endpoint is a sync, not an append:
|
|
28
|
+
* every rule the caller wants to keep must be echoed back with its id, or it is
|
|
29
|
+
* deleted. Callers build `rules` from a fresh read for exactly that reason.
|
|
30
|
+
*/
|
|
31
|
+
export declare function syncTargetingRules(client: AffsetClient, campaignId: string, rules: TargetingRule[]): Promise<TargetingRule[]>;
|
|
32
|
+
/** Strip everything the sync endpoint does not accept back. */
|
|
33
|
+
export declare function toSyncPayload(rules: TargetingRule[]): TargetingRule[];
|
|
34
|
+
/** Resolve a caller-supplied type id or name against an already-fetched catalog. */
|
|
35
|
+
export declare function resolveTargetingType(types: TargetingRuleType[], type: string | number): {
|
|
36
|
+
type: TargetingRuleType;
|
|
37
|
+
} | {
|
|
38
|
+
error: string;
|
|
39
|
+
};
|
|
40
|
+
export type NormalizedRule = {
|
|
41
|
+
value: string;
|
|
42
|
+
notes: string[];
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* Normalise a rule value to the spelling the serve path compares against.
|
|
46
|
+
* Returns an error for values that provably cannot match, and notes for values
|
|
47
|
+
* that were rewritten or that could not be checked.
|
|
48
|
+
*/
|
|
49
|
+
export declare function normalizeRuleValue(typeName: string, rule: string): NormalizedRule | {
|
|
50
|
+
error: string;
|
|
51
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Date-range resolution for stats queries.
|
|
3
|
+
*
|
|
4
|
+
* Every boundary here is computed in the TENANT's timezone, not the timezone of
|
|
5
|
+
* the machine running this server. The API buckets `group_by=date` by the tenant
|
|
6
|
+
* timezone, so resolving "today" against the operator's laptop clock would ask
|
|
7
|
+
* for a window that straddles two of the buckets it gets back — a single "today"
|
|
8
|
+
* arriving as two partial rows. Callers fetch the tenant timezone (see
|
|
9
|
+
* AffsetClient#getTenantTimezone) and pass it in.
|
|
10
|
+
*/
|
|
11
|
+
export declare const RANGE_PRESETS: readonly ["today", "yesterday", "last_7_days", "last_30_days", "this_month"];
|
|
12
|
+
export type RangePreset = (typeof RANGE_PRESETS)[number];
|
|
13
|
+
export interface ResolvedRange {
|
|
14
|
+
/** Inclusive lower bound, epoch ms. */
|
|
15
|
+
from: number;
|
|
16
|
+
/** Inclusive upper bound, epoch ms. */
|
|
17
|
+
to: number;
|
|
18
|
+
/** Human label for display. */
|
|
19
|
+
label: string;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Parse one campaign schedule boundary. Date-only values use the tenant's
|
|
23
|
+
* timezone; explicit ISO timestamps and epoch milliseconds remain exact.
|
|
24
|
+
*/
|
|
25
|
+
export declare function parseCampaignDateBound(value: string | number | null, role: "start" | "end", timeZone: string): number | null;
|
|
26
|
+
/**
|
|
27
|
+
* Resolve a range from an optional preset and/or explicit from/to bounds.
|
|
28
|
+
* Explicit bounds win over the preset. All day boundaries are in `timeZone`
|
|
29
|
+
* (the tenant's), so the window lines up with the API's date buckets.
|
|
30
|
+
*/
|
|
31
|
+
export declare function resolveRange(preset: RangePreset | undefined, from: string | undefined, to: string | undefined, timeZone: string): ResolvedRange;
|
|
32
|
+
/**
|
|
33
|
+
* Format an instant as `YYYY-MM-DD HH:mm` in the tenant timezone. Row timestamps
|
|
34
|
+
* have to agree with the date buckets stats are read in: a conversion at 23:30
|
|
35
|
+
* tenant-local rendered in UTC lands on the next day's row and reads as a
|
|
36
|
+
* missing conversion.
|
|
37
|
+
*/
|
|
38
|
+
export declare function formatInstant(ms: number, timeZone: string): string;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
|
2
|
+
/** Build a successful text tool result. */
|
|
3
|
+
export declare function textResult(body: string): CallToolResult;
|
|
4
|
+
/** Build an error tool result (isError: true). */
|
|
5
|
+
export declare function errorResult(err: unknown): CallToolResult;
|
|
6
|
+
/** Build an error tool result from a plain string. */
|
|
7
|
+
export declare function textError(body: string): CallToolResult;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zone lookup shared by every tool that needs a traffic source: campaign creation
|
|
3
|
+
* and both integration-URL tools all take an optional `zone_id` and auto-pick when
|
|
4
|
+
* the namespace has exactly one active zone.
|
|
5
|
+
*/
|
|
6
|
+
import { type AffsetClient } from "../client.js";
|
|
7
|
+
import type { Zone } from "../types.js";
|
|
8
|
+
export type ResolvedZone = {
|
|
9
|
+
zone: Zone;
|
|
10
|
+
inactiveWarning?: string;
|
|
11
|
+
};
|
|
12
|
+
export type ZoneResolution = ResolvedZone | {
|
|
13
|
+
error: string;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Resolve the zone to build URLs against. An explicit id is fetched directly — the
|
|
17
|
+
* paginated list must not be the arbiter of whether a zone exists.
|
|
18
|
+
*/
|
|
19
|
+
export declare function resolveZone(client: AffsetClient, zoneId: string | undefined): Promise<ZoneResolution>;
|
|
20
|
+
/**
|
|
21
|
+
* The line every integration URL needs under it: without a postback URL on the zone,
|
|
22
|
+
* conversions never make it back to the traffic source and its optimizer stays blind.
|
|
23
|
+
*/
|
|
24
|
+
export declare function zonePostbackNote(zone: Zone): string;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type Config } from "./runtimeConfig.js";
|
|
2
|
+
/**
|
|
3
|
+
* Structural stand-in for the SDK's `McpServer`, so a consumer's own SDK
|
|
4
|
+
* install is accepted. `McpServer` carries private fields, which makes two
|
|
5
|
+
* copies of the class (this package's and a consumer's — e.g. the Workers
|
|
6
|
+
* gateway bundling its own `@modelcontextprotocol/sdk`) nominally
|
|
7
|
+
* incompatible even at identical versions. Method-parameter bivariance makes
|
|
8
|
+
* any real `McpServer` assignable to this shape; nothing else plausibly is.
|
|
9
|
+
*
|
|
10
|
+
* The zod schemas behind `inputSchema` stay internal to this package, so
|
|
11
|
+
* consumers never mix zod instances at the type level (they may bundle zod
|
|
12
|
+
* v4 while this package uses v3 — the MCP SDK detects the flavor per call).
|
|
13
|
+
*/
|
|
14
|
+
export interface AffsetToolServer {
|
|
15
|
+
registerTool(name: string, config: object, callback: (...args: never[]) => unknown): unknown;
|
|
16
|
+
registerResource(name: string, uri: string, metadata: object, callback: never): unknown;
|
|
17
|
+
}
|
|
18
|
+
export interface ToolCallEvent {
|
|
19
|
+
toolName: string;
|
|
20
|
+
durationMs: number;
|
|
21
|
+
status: "ok" | "error";
|
|
22
|
+
}
|
|
23
|
+
export interface RegisterAffsetToolsOptions {
|
|
24
|
+
/**
|
|
25
|
+
* Optional transport-owned audit hook. It receives metadata only, never tool
|
|
26
|
+
* arguments or output. Hook failures are isolated from the tool result.
|
|
27
|
+
*/
|
|
28
|
+
onToolCall?: (event: ToolCallEvent) => void | Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Register the full affset tool roster and docs resources against `config`.
|
|
32
|
+
* The single source of truth for the roster: the stdio entrypoint
|
|
33
|
+
* (`createServer`) and the remote gateway's `McpAgent` both call exactly this,
|
|
34
|
+
* so the two transports cannot drift (REMOTE-MCP-PRD.md §5.6).
|
|
35
|
+
*
|
|
36
|
+
* When `config.readOnly` is set, every tool that is not `readOnlyHint: true`
|
|
37
|
+
* is skipped — identical semantics to AFFSET_READ_ONLY on stdio and to a
|
|
38
|
+
* `read`-scoped OAuth grant on the gateway.
|
|
39
|
+
*/
|
|
40
|
+
export declare function registerAffsetTools(toolServer: AffsetToolServer, config: Config, options?: RegisterAffsetToolsOptions): void;
|