Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion bing-ads/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@channel47/bing-ads-mcp",
"version": "1.3.2",
"version": "1.3.3",
"description": "Microsoft Advertising (Bing Ads) MCP Server - Query campaigns, reports, and mutate entities via REST API",
"main": "server/index.js",
"bin": {
Expand Down
105 changes: 105 additions & 0 deletions bing-ads/server/utils/mutate-operations.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { BING_BASE_URLS } from '../http.js';
import { validateArray, validateOneOf } from './validation.js';

const ACTION_KEYS = ['create', 'update', 'remove'];
const SHOPPING_CAMPAIGN_TYPE = 'Shopping';

const ENTITY_CONFIG = {
campaigns: {
Expand Down Expand Up @@ -67,6 +68,103 @@ const ENTITY_CONFIG = {

const SUPPORTED_ENTITIES = Object.keys(ENTITY_CONFIG);

function isPlainObject(value) {
return value && typeof value === 'object' && !Array.isArray(value);
}

function normalizeBiddingSchemeType(type) {
if (typeof type !== 'string' || type.length === 0) {
return type;
}

return type.endsWith('BiddingScheme')
? type.slice(0, -'BiddingScheme'.length)
: type;
}

function normalizeSettingType(type) {
if (typeof type !== 'string' || type.length === 0) {
return type;
}

if (type === 'Shopping') {
return 'ShoppingSetting';
}

return type.endsWith('Setting') ? type : `${type}Setting`;
}

function getShoppingSetting(settings) {
if (!Array.isArray(settings)) {
return null;
}

return settings.find((setting) => {
if (!isPlainObject(setting)) {
return false;
}

const normalizedType = normalizeSettingType(setting.Type);
return normalizedType === 'ShoppingSetting';
}) || null;
}

function validateCampaignBody(body, action, index, errors) {
if (body.CampaignType !== SHOPPING_CAMPAIGN_TYPE) {
return;
}

const shoppingSetting = getShoppingSetting(body.Settings);

if (!shoppingSetting) {
errors.push({
index,
message: 'Shopping campaign operations require Settings with a ShoppingSetting object'
});
return;
}

if (action === 'create') {
if (shoppingSetting.StoreId === undefined || shoppingSetting.StoreId === null || shoppingSetting.StoreId === '') {
errors.push({
index,
message: 'Shopping campaign create requires ShoppingSetting.StoreId'
});
}

if (shoppingSetting.Priority === undefined || shoppingSetting.Priority === null || shoppingSetting.Priority === '') {
errors.push({
index,
message: 'Shopping campaign create requires ShoppingSetting.Priority'
});
}
}
}

function transformCampaignForApi(campaign) {
const transformed = { ...campaign };

if (isPlainObject(transformed.BiddingScheme)) {
transformed.BiddingScheme = { ...transformed.BiddingScheme };
transformed.BiddingScheme.Type = normalizeBiddingSchemeType(transformed.BiddingScheme.Type);
}

if (Array.isArray(transformed.Settings)) {
transformed.Settings = transformed.Settings.map((setting) => {
if (!isPlainObject(setting)) {
return setting;
}

return {
...setting,
Type: normalizeSettingType(setting.Type)
};
});
}

return transformed;
}

/**
* Extract the action type from an operation object.
*/
Expand Down Expand Up @@ -134,6 +232,10 @@ export function validateOperations(ops, accountId) {
if (action === 'remove' && op.entity !== 'negative_keywords' && !body.Id) {
errors.push({ index: i, message: `"remove" requires Id field` });
}

if (op.entity === 'campaigns') {
validateCampaignBody(body, action, i, errors);
}
}

// Enforce batch limits per (entity, action, parentId) group
Expand Down Expand Up @@ -296,6 +398,9 @@ function buildCreateUpdateRequest(group, accountId, config, endpoint) {
if (entity === 'ads') {
return transformAdForApi(cleaned);
}
if (entity === 'campaigns') {
return transformCampaignForApi(cleaned);
}
return cleaned;
});

Expand Down
48 changes: 48 additions & 0 deletions bing-ads/test/mutate-operations.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,31 @@ describe('validateOperations', () => {
assert.match(errors[0].message, /Batch limit exceeded/);
assert.match(errors[0].message, /max 50/);
});

test('returns error when shopping campaign create is missing ShoppingSetting', () => {
const errors = validateOperations([{
entity: 'campaigns',
create: { Name: 'Shopping Campaign', CampaignType: 'Shopping' }
}], '123');

assert.equal(errors.length, 1);
assert.match(errors[0].message, /require Settings with a ShoppingSetting object/);
});

test('returns error when shopping campaign create is missing StoreId or Priority', () => {
const errors = validateOperations([{
entity: 'campaigns',
create: {
Name: 'Shopping Campaign',
CampaignType: 'Shopping',
Settings: [{ Type: 'ShoppingSetting', SalesCountryCode: 'US' }]
}
}], '123');

assert.equal(errors.length, 2);
assert.ok(errors.some((error) => /StoreId/.test(error.message)));
assert.ok(errors.some((error) => /Priority/.test(error.message)));
});
});

describe('groupOperations', () => {
Expand Down Expand Up @@ -193,6 +218,29 @@ describe('buildApiRequest', () => {
assert.equal(req.body.Campaigns[0].Name, 'Test Campaign');
});

test('normalizes shopping campaign request types for the API', () => {
const group = {
entity: 'campaigns',
action: 'create',
parentId: '123',
items: [
{
index: 0,
body: {
Name: 'Shopping Campaign',
CampaignType: 'Shopping',
BiddingScheme: { Type: 'EnhancedCpcBiddingScheme' },
Settings: [{ Type: 'Shopping', StoreId: 3510637, Priority: 0, SalesCountryCode: 'US' }]
}
}
]
};

const req = buildApiRequest(group, '123');
assert.equal(req.body.Campaigns[0].BiddingScheme.Type, 'EnhancedCpc');
assert.equal(req.body.Campaigns[0].Settings[0].Type, 'ShoppingSetting');
});

test('builds keyword create request with parent field stripped', () => {
const group = {
entity: 'keywords',
Expand Down
35 changes: 35 additions & 0 deletions bing-ads/test/mutate.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,41 @@ describe('mutate — live execution', () => {
assert.equal(capturedRequest.context.method, 'POST');
});

test('normalizes shopping campaign create payload before request dispatch', async () => {
let capturedRequest = null;

const result = await mutate(
{
operations: [
{
entity: 'campaigns',
create: {
Name: 'Shopping Campaign',
BudgetType: 'DailyBudgetStandard',
DailyBudget: 30,
CampaignType: 'Shopping',
BiddingScheme: { Type: 'EnhancedCpcBiddingScheme' },
Settings: [{ Type: 'Shopping', StoreId: 3510637, Priority: 0, SalesCountryCode: 'US' }]
}
}
],
dry_run: false
},
{
request: async (url, body, context) => {
capturedRequest = { url, body, context };
return MOCK_CAMPAIGNS_ADD_RESPONSE;
}
}
);

const payload = parseResult(result);
assert.equal(payload.success, true);
assert.equal(payload.metadata.succeeded, 1);
assert.equal(capturedRequest.body.Campaigns[0].BiddingScheme.Type, 'EnhancedCpc');
assert.equal(capturedRequest.body.Campaigns[0].Settings[0].Type, 'ShoppingSetting');
});

test('creates keywords with all succeeding', async () => {
const result = await mutate(
{
Expand Down