Skip to content

Commit 89bb8be

Browse files
committed
feat: period-aggregate defaults + L3 input guards + deterministic getAll cap (1.0.2)
- LD: drop Date from default report fields (period aggregate, not daily split) - L3: fail-loud on ALL_TIME without campaign filter and on explicit filter -> 0 rows - L3: clamp list_* limit to MAX_TOOL_LIMIT=1000 (single-page); getAll pages at DEFAULT_PAGE_LIMIT deterministically, maxPages=100 (~1M cap), loud _truncated marker
1 parent 106bf63 commit 89bb8be

11 files changed

Lines changed: 131 additions & 25 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "mcp-yandex-direct",
3-
"version": "1.0.1",
3+
"version": "1.0.2",
44
"description": "MCP server for the Yandex Direct API v5 — manage PPC campaigns, ad groups, ads, keywords and pull statistics from AI agents.",
55
"type": "module",
66
"bin": {

src/client.test.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ test("getAll merges pages by following LimitedBy and clears it when done", async
127127
}
128128
});
129129

130-
test("getAll stops at maxPages and keeps LimitedBy as a 'more remains' signal", async () => {
130+
test("getAll stops at maxPages and flags the truncation loudly", async () => {
131131
let calls = 0;
132132
const mock = mockFetch(() => {
133133
calls++;
@@ -138,13 +138,17 @@ test("getAll stops at maxPages and keeps LimitedBy as a 'more remains' signal",
138138
});
139139
try {
140140
const client = new YandexDirectClient({ token: "T", lang: "ru", sandbox: true });
141-
const result = await client.getAll<{ Campaigns: unknown[]; LimitedBy?: number }>(
142-
"campaigns",
143-
{},
144-
2,
145-
);
141+
const result = await client.getAll<{
142+
Campaigns: unknown[];
143+
LimitedBy?: number;
144+
_truncated?: boolean;
145+
_truncatedNote?: string;
146+
}>("campaigns", {}, 2);
146147
assert.equal(calls, 2);
147148
assert.equal(result.Campaigns.length, 2);
149+
// Hitting the cap is explicit, not a bare LimitedBy that the model may ignore.
150+
assert.equal(result._truncated, true);
151+
assert.match(result._truncatedNote ?? "", /more objects remain/);
148152
assert.notEqual(result.LimitedBy, undefined);
149153
} finally {
150154
mock.restore();

src/client.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -129,16 +129,26 @@ export class YandexDirectClient {
129129
/**
130130
* Runs a `get` request, following the LimitedBy cursor to fetch every page
131131
* and merging the entity array, so large accounts are not silently truncated.
132-
* Bounded by maxPages; if the cap is hit, LimitedBy is kept as a "more remains"
133-
* signal.
132+
* Pages at DEFAULT_PAGE_LIMIT (10k) regardless of the per-tool `limit` clamp
133+
* (which governs single-page calls only), so capacity is a DETERMINISTIC
134+
* DEFAULT_PAGE_LIMIT × maxPages ≈ 1M objects — not path-dependent. maxPages is a
135+
* safety stop for runaway loops, not a cost lever (context size is bounded
136+
* downstream by the backend's per-result cap). If the cap IS hit, the merged
137+
* result is flagged with `_truncated`/`_truncatedNote` and keeps LimitedBy, so a
138+
* truncated full-export is explicit and never silent data loss.
134139
*/
135140
async getAll<T = unknown>(
136141
service: string,
137142
params: Record<string, unknown>,
138143
maxPages = 100,
139144
): Promise<T> {
140145
const basePage = (params.Page as Record<string, unknown> | undefined) ?? {};
141-
const limit = Number(basePage.Limit ?? DEFAULT_PAGE_LIMIT);
146+
// autoPaginate ("fetch all") ALWAYS pages at the API max, independent of the
147+
// per-tool `limit` clamp (which governs single-page calls only). This keeps
148+
// capacity deterministic — DEFAULT_PAGE_LIMIT × maxPages ≈ 1M — instead of
149+
// path-dependent (a caller passing limit:1000 alongside autoPaginate must not
150+
// silently shrink the export ceiling to 100k).
151+
const limit = DEFAULT_PAGE_LIMIT;
142152
let offset = Number(basePage.Offset ?? 0);
143153
let merged: Record<string, unknown> | undefined;
144154
let entityKey: string | undefined;
@@ -161,6 +171,17 @@ export class YandexDirectClient {
161171
}
162172
offset = limitedBy;
163173
}
174+
// Reached the page cap with LimitedBy still set → more objects remain. Make this
175+
// LOUD: a bare LimitedBy number is easy for an LLM consumer to miss, and a silently
176+
// truncated full-export is legitimate-data loss (the backend's char-cap only flags
177+
// size, not pagination cutoff).
178+
if (merged && typeof (merged as Record<string, unknown>).LimitedBy === "number") {
179+
const m = merged as Record<string, unknown>;
180+
m._truncated = true;
181+
m._truncatedNote =
182+
`Stopped at the ${maxPages}-page cap; more objects remain (LimitedBy=${m.LimitedBy}). ` +
183+
"Narrow the filter or paginate manually with offset to get the rest.";
184+
}
164185
return merged as T;
165186
}
166187

src/tools/adGroups.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
22
import { z } from "zod";
33
import type { YandexDirectClient } from "../client.js";
4-
import { buildPage, compact, fail, ok, okOrPartial } from "./util.js";
4+
import { buildPage, compact, fail, MAX_TOOL_LIMIT, ok, okOrPartial } from "./util.js";
55

66
const DEFAULT_FIELDS = ["Id", "Name", "CampaignId", "RegionIds", "Status", "Type"];
77

@@ -16,7 +16,7 @@ export function registerAdGroupTools(server: McpServer, client: YandexDirectClie
1616
campaignIds: z.array(z.number().int()).optional().describe("Filter by campaign ids."),
1717
ids: z.array(z.number().int()).optional().describe("Filter by ad group ids."),
1818
fieldNames: z.array(z.string()).optional().describe("Ad group fields to return."),
19-
limit: z.number().int().min(1).max(10000).optional().describe("Max objects per page."),
19+
limit: z.number().int().min(1).max(MAX_TOOL_LIMIT).optional().describe("Max objects per page."),
2020
offset: z.number().int().min(0).optional().describe("Pagination offset (objects to skip)."),
2121
autoPaginate: z
2222
.boolean()

src/tools/ads.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
22
import { z } from "zod";
33
import type { YandexDirectClient } from "../client.js";
4-
import { buildPage, compact, fail, ok, okOrPartial } from "./util.js";
4+
import { buildPage, compact, fail, MAX_TOOL_LIMIT, ok, okOrPartial } from "./util.js";
55

66
const AD_STATES = ["ON", "OFF", "SUSPENDED", "OFF_BY_MONITORING", "ARCHIVED"] as const;
77
const AD_STATUSES = ["ACCEPTED", "DRAFT", "MODERATION", "PREACCEPTED", "REJECTED"] as const;
@@ -21,7 +21,7 @@ export function registerAdTools(server: McpServer, client: YandexDirectClient):
2121
states: z.array(z.enum(AD_STATES)).optional().describe("Filter by ad states."),
2222
statuses: z.array(z.enum(AD_STATUSES)).optional().describe("Filter by moderation statuses."),
2323
fieldNames: z.array(z.string()).optional().describe("Ad fields to return."),
24-
limit: z.number().int().min(1).max(10000).optional().describe("Max objects per page."),
24+
limit: z.number().int().min(1).max(MAX_TOOL_LIMIT).optional().describe("Max objects per page."),
2525
offset: z.number().int().min(0).optional().describe("Pagination offset (objects to skip)."),
2626
autoPaginate: z
2727
.boolean()

src/tools/campaigns.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
22
import { z } from "zod";
33
import type { YandexDirectClient } from "../client.js";
4-
import { buildPage, compact, fail, isoDate, normalizeMoney, ok, okOrPartial, toMicros } from "./util.js";
4+
import { buildPage, compact, fail, isoDate, MAX_TOOL_LIMIT, normalizeMoney, ok, okOrPartial, toMicros } from "./util.js";
55

66
const CAMPAIGN_TYPES = [
77
"TEXT_CAMPAIGN",
@@ -53,7 +53,7 @@ export function registerCampaignTools(server: McpServer, client: YandexDirectCli
5353
.optional()
5454
.describe("Filter by moderation statuses."),
5555
fieldNames: z.array(z.string()).optional().describe("Campaign fields to return."),
56-
limit: z.number().int().min(1).max(10000).optional().describe("Max objects per page."),
56+
limit: z.number().int().min(1).max(MAX_TOOL_LIMIT).optional().describe("Max objects per page."),
5757
offset: z.number().int().min(0).optional().describe("Pagination offset (objects to skip)."),
5858
autoPaginate: z
5959
.boolean()

src/tools/handlers.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,35 @@ test("get_statistics errors on CUSTOM_DATE without both dates and makes no reque
7272
assert.equal(reports.length, 0);
7373
});
7474

75+
test("ALL_TIME without a campaign filter is rejected for SEARCH_QUERY (no request)", async () => {
76+
const { reports, tools } = harness(registerStatisticsTools, { reportResult: "TSV" });
77+
const res = await tools.get_statistics({
78+
reportType: "SEARCH_QUERY_PERFORMANCE_REPORT",
79+
dateRangeType: "ALL_TIME",
80+
});
81+
assert.equal(res.isError, true);
82+
assert.equal(reports.length, 0);
83+
});
84+
85+
test("ALL_TIME is allowed for SEARCH_QUERY when a campaign filter is present", async () => {
86+
const { reports, tools } = harness(registerStatisticsTools, { reportResult: "TSV" });
87+
const res = await tools.get_statistics({
88+
reportType: "SEARCH_QUERY_PERFORMANCE_REPORT",
89+
dateRangeType: "ALL_TIME",
90+
campaignIds: [1],
91+
});
92+
assert.ok(!res.isError);
93+
assert.equal(reports.length, 1);
94+
});
95+
96+
test("explicit campaign filter with 0 rows fails loud (L3)", async () => {
97+
const { reports, tools } = harness(registerStatisticsTools, { reportResult: "" });
98+
const res = await tools.get_statistics({ campaignIds: [123], dateRangeType: "LAST_7_DAYS" });
99+
assert.equal(res.isError, true);
100+
assert.equal(reports.length, 1); // запрос сделан; ошибка — по факту 0 строк
101+
assert.match(res.content[0].text, /0 rows/);
102+
});
103+
75104
test("create_text_campaign applies the default strategy and converts the budget", async () => {
76105
const { calls, tools } = harness(registerCampaignTools, { callResult: { AddResults: [{ Id: 1 }] } });
77106
await tools.create_text_campaign({ name: "C", startDate: "2026-01-01", dailyBudgetAmount: 500 });

src/tools/keywords.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
22
import { z } from "zod";
33
import type { YandexDirectClient } from "../client.js";
4-
import { buildPage, compact, fail, normalizeMoney, ok, okOrPartial, toMicros } from "./util.js";
4+
import { buildPage, compact, fail, MAX_TOOL_LIMIT, normalizeMoney, ok, okOrPartial, toMicros } from "./util.js";
55

66
const DEFAULT_FIELDS = ["Id", "Keyword", "AdGroupId", "CampaignId", "Bid", "ContextBid", "State", "Status"];
77

@@ -17,7 +17,7 @@ export function registerKeywordTools(server: McpServer, client: YandexDirectClie
1717
adGroupIds: z.array(z.number().int()).optional().describe("Filter by ad group ids."),
1818
ids: z.array(z.number().int()).optional().describe("Filter by keyword ids."),
1919
fieldNames: z.array(z.string()).optional().describe("Keyword fields to return."),
20-
limit: z.number().int().min(1).max(10000).optional().describe("Max objects per page."),
20+
limit: z.number().int().min(1).max(MAX_TOOL_LIMIT).optional().describe("Max objects per page."),
2121
offset: z.number().int().min(0).optional().describe("Pagination offset (objects to skip)."),
2222
autoPaginate: z
2323
.boolean()

src/tools/statistics.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,24 @@ test("ACCOUNT_PERFORMANCE_REPORT defaults exclude fields the API rejects for it"
1414
assert.ok(!fields.includes(forbidden), `${forbidden} must not be a default`);
1515
}
1616
});
17+
18+
test("defaults exclude Date so reports aggregate over the period (LD)", () => {
19+
for (const type of REPORT_TYPES) {
20+
assert.ok(
21+
!DEFAULT_FIELDS_BY_TYPE[type].includes("Date"),
22+
`${type} default must not include Date (would split the report by day)`,
23+
);
24+
}
25+
});
26+
27+
test("SEARCH_QUERY default is the period aggregate: CampaignName + Query + metrics", () => {
28+
assert.deepEqual(DEFAULT_FIELDS_BY_TYPE.SEARCH_QUERY_PERFORMANCE_REPORT, [
29+
"CampaignName",
30+
"Query",
31+
"Impressions",
32+
"Clicks",
33+
"Cost",
34+
"Ctr",
35+
"AvgCpc",
36+
]);
37+
});

src/tools/statistics.ts

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,21 +32,26 @@ const METRICS = ["Impressions", "Clicks", "Cost", "Ctr", "AvgCpc"];
3232
* dimension fields — e.g. ACCOUNT_PERFORMANCE_REPORT rejects CampaignName — so
3333
* a single shared default cannot work. All sets below are verified against the
3434
* live Reports service.
35+
*
36+
* `Date` is intentionally OMITTED from the defaults: in the Reports service Date
37+
* is a grouping dimension, so including it splits the report by day (one row per
38+
* object × day → up to ×30 rows on LAST_30_DAYS). The default is a period-
39+
* aggregate (one row per object); a caller asking about daily dynamics/trends
40+
* adds "Date" to fieldNames explicitly.
3541
*/
3642
export const DEFAULT_FIELDS_BY_TYPE: Record<ReportType, string[]> = {
37-
ACCOUNT_PERFORMANCE_REPORT: ["Date", ...METRICS],
38-
CAMPAIGN_PERFORMANCE_REPORT: ["Date", "CampaignId", "CampaignName", ...METRICS],
39-
ADGROUP_PERFORMANCE_REPORT: ["Date", "CampaignName", "AdGroupId", "AdGroupName", ...METRICS],
40-
AD_PERFORMANCE_REPORT: ["Date", "CampaignName", "AdGroupName", "AdId", ...METRICS],
43+
ACCOUNT_PERFORMANCE_REPORT: [...METRICS],
44+
CAMPAIGN_PERFORMANCE_REPORT: ["CampaignId", "CampaignName", ...METRICS],
45+
ADGROUP_PERFORMANCE_REPORT: ["CampaignName", "AdGroupId", "AdGroupName", ...METRICS],
46+
AD_PERFORMANCE_REPORT: ["CampaignName", "AdGroupName", "AdId", ...METRICS],
4147
CRITERIA_PERFORMANCE_REPORT: [
42-
"Date",
4348
"CampaignName",
4449
"AdGroupName",
4550
"CriterionId",
4651
"Criterion",
4752
...METRICS,
4853
],
49-
SEARCH_QUERY_PERFORMANCE_REPORT: ["Date", "CampaignName", "Query", ...METRICS],
54+
SEARCH_QUERY_PERFORMANCE_REPORT: ["CampaignName", "Query", ...METRICS],
5055
};
5156

5257
export function registerStatisticsTools(server: McpServer, client: YandexDirectClient): void {
@@ -55,7 +60,7 @@ export function registerStatisticsTools(server: McpServer, client: YandexDirectC
5560
{
5661
title: "Get statistics",
5762
description:
58-
"Requests a TSV performance report via the Yandex Direct Reports service. Returns the report as tab-separated text with a column header row.",
63+
"Requests a TSV performance report via the Yandex Direct Reports service. Returns tab-separated rows (no header row). By default the report is AGGREGATED over the whole period (one row per object) — add \"Date\" to fieldNames only for day-by-day dynamics or trend questions. ALL_TIME without a campaign filter is rejected for SEARCH_QUERY/CRITERIA reports; pass campaignIds or a bounded date range.",
5964
inputSchema: {
6065
reportType: z.enum(REPORT_TYPES).optional().describe("Report type. Default CAMPAIGN_PERFORMANCE_REPORT."),
6166
dateRangeType: z
@@ -74,6 +79,16 @@ export function registerStatisticsTools(server: McpServer, client: YandexDirectC
7479
const type = reportType ?? "CAMPAIGN_PERFORMANCE_REPORT";
7580
const range = dateRangeType ?? (dateFrom && dateTo ? "CUSTOM_DATE" : "LAST_30_DAYS");
7681

82+
// L3: ALL_TIME без фильтра по кампании для построчных «тяжёлых» отчётов тянет
83+
// весь аккаунт за всё время → взрыв размера. Падаем громко, до запроса.
84+
const heavy =
85+
type === "SEARCH_QUERY_PERFORMANCE_REPORT" || type === "CRITERIA_PERFORMANCE_REPORT";
86+
if (range === "ALL_TIME" && heavy && !campaignIds?.length) {
87+
return fail(
88+
`ALL_TIME without a campaign filter is not allowed for ${type} (it returns the whole account and explodes in size). Pass campaignIds, or a bounded date range like LAST_30_DAYS or CUSTOM_DATE.`,
89+
);
90+
}
91+
7792
const selection: Record<string, unknown> = {};
7893
if (range === "CUSTOM_DATE") {
7994
if (!dateFrom || !dateTo) {
@@ -100,6 +115,14 @@ export function registerStatisticsTools(server: McpServer, client: YandexDirectC
100115
};
101116

102117
const tsv = await client.report(params);
118+
// L3 fail-loud: явный фильтр по кампании, но 0 строк — почти всегда неверный
119+
// campaignId или период. Пустой ответ провоцирует модель «снять фильтр и
120+
// расширить охват»; явная ошибка заставляет починить фильтр.
121+
if (campaignIds?.length && tsv.trim() === "") {
122+
return fail(
123+
`Report returned 0 rows for campaignIds [${campaignIds.join(", ")}] over ${range}. Check the campaignId(s) and the date range — do not broaden the filter blindly.`,
124+
);
125+
}
103126
return ok(tsv);
104127
} catch (e) {
105128
return fail(e);

0 commit comments

Comments
 (0)