From cd6b29bca0c84967e4387957071e9fa5328baf61 Mon Sep 17 00:00:00 2001 From: Jackson Date: Fri, 27 Mar 2026 11:52:09 -0700 Subject: [PATCH 1/2] Fix Bing Shopping campaign mutate requests --- bing-ads/server/utils/mutate-operations.js | 105 +++++++++++++++++++++ bing-ads/test/mutate-operations.test.js | 48 ++++++++++ bing-ads/test/mutate.test.js | 35 +++++++ 3 files changed, 188 insertions(+) diff --git a/bing-ads/server/utils/mutate-operations.js b/bing-ads/server/utils/mutate-operations.js index 4e9beba..4de7ce1 100644 --- a/bing-ads/server/utils/mutate-operations.js +++ b/bing-ads/server/utils/mutate-operations.js @@ -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: { @@ -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. */ @@ -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 @@ -296,6 +398,9 @@ function buildCreateUpdateRequest(group, accountId, config, endpoint) { if (entity === 'ads') { return transformAdForApi(cleaned); } + if (entity === 'campaigns') { + return transformCampaignForApi(cleaned); + } return cleaned; }); diff --git a/bing-ads/test/mutate-operations.test.js b/bing-ads/test/mutate-operations.test.js index f7e831a..cb351b8 100644 --- a/bing-ads/test/mutate-operations.test.js +++ b/bing-ads/test/mutate-operations.test.js @@ -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', () => { @@ -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', diff --git a/bing-ads/test/mutate.test.js b/bing-ads/test/mutate.test.js index 5f63137..dab4c39 100644 --- a/bing-ads/test/mutate.test.js +++ b/bing-ads/test/mutate.test.js @@ -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( { From 8f7d4bb2203ba5dd273b30d5aa5ab5eeb7b47e92 Mon Sep 17 00:00:00 2001 From: Jackson Date: Fri, 27 Mar 2026 12:04:13 -0700 Subject: [PATCH 2/2] chore(bing-ads): bump version to 1.3.3 Co-Authored-By: Claude Opus 4.6 (1M context) --- bing-ads/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bing-ads/package.json b/bing-ads/package.json index 1ebccb3..cd37363 100644 --- a/bing-ads/package.json +++ b/bing-ads/package.json @@ -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": {