@channel47/linkedin-ads-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/LICENSE +21 -0
- package/README.md +177 -0
- package/package.json +47 -0
- package/server/auth.js +146 -0
- package/server/http.js +216 -0
- package/server/index.js +254 -0
- package/server/prompts/templates.js +134 -0
- package/server/resources/analytics-fields-reference.md +63 -0
- package/server/resources/index.js +65 -0
- package/server/resources/marketing-api-reference.md +98 -0
- package/server/resources/rate-limits-and-quotas.md +34 -0
- package/server/tools/analytics.js +145 -0
- package/server/tools/list-accounts.js +117 -0
- package/server/tools/mutate.js +117 -0
- package/server/tools/query.js +147 -0
- package/server/utils/errors.js +12 -0
- package/server/utils/mutate-operations.js +211 -0
- package/server/utils/response-format.js +63 -0
- package/server/utils/restli.js +153 -0
- package/server/utils/validation.js +104 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { linkedinRequest } from '../http.js';
|
|
2
|
+
import { formatError, formatSuccess } from '../utils/response-format.js';
|
|
3
|
+
import { invalidParamsError } from '../utils/errors.js';
|
|
4
|
+
import { rawParam, restliDateRange } from '../utils/restli.js';
|
|
5
|
+
import { getAccountId, toEntityUrn, validateEnum, validateRequired } from '../utils/validation.js';
|
|
6
|
+
|
|
7
|
+
export const SUPPORTED_PIVOTS = ['ACCOUNT', 'CAMPAIGN_GROUP', 'CAMPAIGN', 'CREATIVE'];
|
|
8
|
+
export const SUPPORTED_TIME_GRANULARITIES = ['ALL', 'DAILY', 'MONTHLY'];
|
|
9
|
+
export const SUPPORTED_ANALYTICS_ENTITY_TYPES = ['account', 'campaign_group', 'campaign', 'creative'];
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Default adAnalytics fields projection. dateRange and pivotValues are
|
|
13
|
+
* dimensional fields; the rest are metrics.
|
|
14
|
+
*/
|
|
15
|
+
export const DEFAULT_ANALYTICS_FIELDS = [
|
|
16
|
+
'impressions',
|
|
17
|
+
'clicks',
|
|
18
|
+
'costInLocalCurrency',
|
|
19
|
+
'externalWebsiteConversions',
|
|
20
|
+
'dateRange',
|
|
21
|
+
'pivotValues'
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const NON_METRIC_FIELDS = new Set(['dateRange', 'pivotValues']);
|
|
25
|
+
const MAX_METRIC_FIELDS = 20;
|
|
26
|
+
const MAX_ELEMENTS_NOTE = 'adAnalytics does not support pagination; LinkedIn caps responses at 15,000 elements';
|
|
27
|
+
|
|
28
|
+
const FACET_PARAM_BY_ENTITY_TYPE = {
|
|
29
|
+
account: 'accounts',
|
|
30
|
+
campaign_group: 'campaignGroups',
|
|
31
|
+
campaign: 'campaigns',
|
|
32
|
+
creative: 'creatives'
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function resolveFields(fieldsValue) {
|
|
36
|
+
if (fieldsValue === undefined || fieldsValue === null || fieldsValue === '') {
|
|
37
|
+
return [...DEFAULT_ANALYTICS_FIELDS];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const fields = (Array.isArray(fieldsValue) ? fieldsValue : String(fieldsValue).split(','))
|
|
41
|
+
.map((field) => String(field).trim())
|
|
42
|
+
.filter(Boolean);
|
|
43
|
+
|
|
44
|
+
if (fields.length === 0) {
|
|
45
|
+
return [...DEFAULT_ANALYTICS_FIELDS];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
for (const field of fields) {
|
|
49
|
+
if (!/^[A-Za-z][A-Za-z0-9]*$/.test(field)) {
|
|
50
|
+
throw invalidParamsError(`Invalid analytics field name: ${field}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const metricCount = fields.filter((field) => !NON_METRIC_FIELDS.has(field)).length;
|
|
55
|
+
if (metricCount > MAX_METRIC_FIELDS) {
|
|
56
|
+
throw invalidParamsError(`Too many metric fields: ${metricCount}. LinkedIn adAnalytics allows at most ${MAX_METRIC_FIELDS} metrics per call`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return fields;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function resolveEntityUrns(params) {
|
|
63
|
+
const entityType = params.entity_type ? String(params.entity_type).toLowerCase() : 'account';
|
|
64
|
+
validateEnum(entityType, SUPPORTED_ANALYTICS_ENTITY_TYPES, 'entity_type');
|
|
65
|
+
|
|
66
|
+
const facetParam = FACET_PARAM_BY_ENTITY_TYPE[entityType];
|
|
67
|
+
|
|
68
|
+
if (Array.isArray(params.entity_ids) && params.entity_ids.length > 0) {
|
|
69
|
+
return {
|
|
70
|
+
entityType,
|
|
71
|
+
facetParam,
|
|
72
|
+
urns: params.entity_ids.map((id) => toEntityUrn(entityType, id))
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (entityType !== 'account') {
|
|
77
|
+
throw invalidParamsError(`entity_ids is required when entity_type is ${entityType}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const accountId = getAccountId(params);
|
|
81
|
+
return {
|
|
82
|
+
entityType,
|
|
83
|
+
facetParam,
|
|
84
|
+
urns: [toEntityUrn('account', accountId)]
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Fetch adAnalytics metrics with a pivot, date range, and time granularity.
|
|
90
|
+
* Entity scope is built from plain IDs plus entity_type (defaults to the
|
|
91
|
+
* configured account). The endpoint has no pagination.
|
|
92
|
+
* @param {Record<string, unknown>} [params]
|
|
93
|
+
* @param {{ request?: (path: string, params: Record<string, unknown>) => Promise<any> }} [dependencies]
|
|
94
|
+
* @returns {Promise<import('@modelcontextprotocol/sdk/types.js').CallToolResult>}
|
|
95
|
+
*/
|
|
96
|
+
export async function analytics(params = {}, dependencies = {}) {
|
|
97
|
+
const request = dependencies.request || linkedinRequest;
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
validateRequired(params, ['pivot', 'start']);
|
|
101
|
+
|
|
102
|
+
const pivot = String(params.pivot).toUpperCase();
|
|
103
|
+
validateEnum(pivot, SUPPORTED_PIVOTS, 'pivot');
|
|
104
|
+
|
|
105
|
+
const timeGranularity = params.time_granularity
|
|
106
|
+
? String(params.time_granularity).toUpperCase()
|
|
107
|
+
: 'ALL';
|
|
108
|
+
validateEnum(timeGranularity, SUPPORTED_TIME_GRANULARITIES, 'time_granularity');
|
|
109
|
+
|
|
110
|
+
const dateRange = restliDateRange(params.start, params.end);
|
|
111
|
+
const fields = resolveFields(params.fields);
|
|
112
|
+
const { entityType, facetParam, urns } = resolveEntityUrns(params);
|
|
113
|
+
|
|
114
|
+
const queryParams = {
|
|
115
|
+
q: 'analytics',
|
|
116
|
+
pivot,
|
|
117
|
+
dateRange: rawParam(dateRange),
|
|
118
|
+
timeGranularity,
|
|
119
|
+
[facetParam]: urns,
|
|
120
|
+
fields: rawParam(fields.join(','))
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const response = await request('/adAnalytics', queryParams);
|
|
124
|
+
const rows = Array.isArray(response?.elements) ? response.elements : [];
|
|
125
|
+
|
|
126
|
+
return formatSuccess({
|
|
127
|
+
summary: `Returned ${rows.length} analytics row${rows.length === 1 ? '' : 's'} pivoted by ${pivot} (${timeGranularity})`,
|
|
128
|
+
data: rows,
|
|
129
|
+
metadata: {
|
|
130
|
+
pivot,
|
|
131
|
+
timeGranularity,
|
|
132
|
+
dateRange: {
|
|
133
|
+
start: params.start,
|
|
134
|
+
end: params.end || null
|
|
135
|
+
},
|
|
136
|
+
entityType,
|
|
137
|
+
entityCount: urns.length,
|
|
138
|
+
fields,
|
|
139
|
+
paginationNote: MAX_ELEMENTS_NOTE
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
} catch (error) {
|
|
143
|
+
return formatError(error);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { linkedinRequest } from '../http.js';
|
|
2
|
+
import { formatError, formatSuccess } from '../utils/response-format.js';
|
|
3
|
+
import { rawParam, restliSearchFilter } from '../utils/restli.js';
|
|
4
|
+
import { validateEnum } from '../utils/validation.js';
|
|
5
|
+
|
|
6
|
+
const ACCOUNT_STATUSES = ['ACTIVE', 'CANCELED', 'DRAFT', 'PENDING_DELETION', 'REMOVED'];
|
|
7
|
+
const ACCOUNT_TYPES = ['BUSINESS', 'ENTERPRISE'];
|
|
8
|
+
const MAX_PAGE_SIZE = 1000;
|
|
9
|
+
const DEFAULT_LIMIT = 1000;
|
|
10
|
+
|
|
11
|
+
function normalizeFilterValues(value, allowed, paramName) {
|
|
12
|
+
if (value === undefined || value === null || value === '') {
|
|
13
|
+
return [];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const values = (Array.isArray(value) ? value : String(value).split(','))
|
|
17
|
+
.map((entry) => String(entry).trim().toUpperCase())
|
|
18
|
+
.filter(Boolean);
|
|
19
|
+
|
|
20
|
+
for (const entry of values) {
|
|
21
|
+
validateEnum(entry, allowed, paramName);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return values;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function getRequestedLimit(limitValue) {
|
|
28
|
+
const parsed = Number(limitValue);
|
|
29
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
30
|
+
return DEFAULT_LIMIT;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return Math.floor(parsed);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function mapAccount(account) {
|
|
37
|
+
return {
|
|
38
|
+
id: account?.id ?? null,
|
|
39
|
+
name: account?.name || '',
|
|
40
|
+
status: account?.status || 'UNKNOWN',
|
|
41
|
+
currency: account?.currency || null,
|
|
42
|
+
type: account?.type || null,
|
|
43
|
+
test: Boolean(account?.test),
|
|
44
|
+
reference: account?.reference || null,
|
|
45
|
+
serving_statuses: Array.isArray(account?.servingStatuses) ? account.servingStatuses : []
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function fetchAllPages(request, path, baseParams, requestedLimit) {
|
|
50
|
+
const rows = [];
|
|
51
|
+
let pageToken = null;
|
|
52
|
+
|
|
53
|
+
while (rows.length < requestedLimit) {
|
|
54
|
+
const pageParams = {
|
|
55
|
+
...baseParams,
|
|
56
|
+
pageSize: Math.min(requestedLimit - rows.length, MAX_PAGE_SIZE)
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
if (pageToken) {
|
|
60
|
+
pageParams.pageToken = pageToken;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const response = await request(path, pageParams);
|
|
64
|
+
const elements = Array.isArray(response?.elements) ? response.elements : [];
|
|
65
|
+
rows.push(...elements);
|
|
66
|
+
|
|
67
|
+
pageToken = response?.metadata?.nextPageToken;
|
|
68
|
+
if (!pageToken || elements.length === 0) {
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return rows.slice(0, requestedLimit);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* List accessible LinkedIn ad accounts via the adAccounts search finder with
|
|
78
|
+
* cursor (pageToken/pageSize) pagination.
|
|
79
|
+
* @param {{ status?: string | string[], type?: string | string[], limit?: number }} [params]
|
|
80
|
+
* @param {{ request?: (path: string, params: Record<string, unknown>) => Promise<any> }} [dependencies]
|
|
81
|
+
* @returns {Promise<import('@modelcontextprotocol/sdk/types.js').CallToolResult>}
|
|
82
|
+
*/
|
|
83
|
+
export async function listAccounts(params = {}, dependencies = {}) {
|
|
84
|
+
const request = dependencies.request || linkedinRequest;
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
const statusValues = normalizeFilterValues(params.status, ACCOUNT_STATUSES, 'status');
|
|
88
|
+
const typeValues = normalizeFilterValues(params.type, ACCOUNT_TYPES, 'type');
|
|
89
|
+
const requestedLimit = getRequestedLimit(params.limit);
|
|
90
|
+
|
|
91
|
+
const queryParams = {
|
|
92
|
+
q: 'search'
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const searchFilter = restliSearchFilter({
|
|
96
|
+
status: statusValues,
|
|
97
|
+
type: typeValues
|
|
98
|
+
});
|
|
99
|
+
if (searchFilter) {
|
|
100
|
+
queryParams.search = rawParam(searchFilter);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const accounts = (await fetchAllPages(request, '/adAccounts', queryParams, requestedLimit)).map(mapAccount);
|
|
104
|
+
|
|
105
|
+
return formatSuccess({
|
|
106
|
+
summary: `Found ${accounts.length} accessible LinkedIn ad account${accounts.length === 1 ? '' : 's'}`,
|
|
107
|
+
data: accounts,
|
|
108
|
+
metadata: {
|
|
109
|
+
totalAccounts: accounts.length,
|
|
110
|
+
appliedStatusFilter: statusValues.length > 0 ? statusValues : null,
|
|
111
|
+
appliedTypeFilter: typeValues.length > 0 ? typeValues : null
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
} catch (error) {
|
|
115
|
+
return formatError(error);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { linkedinRequest } from '../http.js';
|
|
2
|
+
import { formatError, formatSuccess } from '../utils/response-format.js';
|
|
3
|
+
import { invalidParamsError } from '../utils/errors.js';
|
|
4
|
+
import { getAccountId } from '../utils/validation.js';
|
|
5
|
+
import {
|
|
6
|
+
buildApiRequest,
|
|
7
|
+
buildRequestPreview,
|
|
8
|
+
validateOperations
|
|
9
|
+
} from '../utils/mutate-operations.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Validate and execute LinkedIn Ads mutation operations with dry-run safety
|
|
13
|
+
* defaults. LinkedIn has no server-side validate-only mode, so dry_run
|
|
14
|
+
* (default true) performs local validation and returns a preview of the exact
|
|
15
|
+
* requests (method, path, headers, body) without calling the API.
|
|
16
|
+
* @param {Record<string, unknown>} [params]
|
|
17
|
+
* @param {{ request?: (path: string, params: Record<string, unknown>, options?: Record<string, unknown>) => Promise<any> }} [dependencies]
|
|
18
|
+
* @returns {Promise<import('@modelcontextprotocol/sdk/types.js').CallToolResult>}
|
|
19
|
+
*/
|
|
20
|
+
export async function mutate(params = {}, dependencies = {}) {
|
|
21
|
+
const request = dependencies.request || linkedinRequest;
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const operations = params.operations;
|
|
25
|
+
const accountId = getAccountId(params);
|
|
26
|
+
const partialFailure = params.partial_failure === undefined ? true : Boolean(params.partial_failure);
|
|
27
|
+
const dryRun = params.dry_run === undefined ? true : Boolean(params.dry_run);
|
|
28
|
+
|
|
29
|
+
const validationErrors = validateOperations(operations);
|
|
30
|
+
if (validationErrors.length > 0) {
|
|
31
|
+
const errorMessage = validationErrors.map((issue) => `[${issue.index}] ${issue.message}`).join('; ');
|
|
32
|
+
throw invalidParamsError(`Invalid operations: ${errorMessage}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (dryRun) {
|
|
36
|
+
const preview = buildRequestPreview(operations, accountId);
|
|
37
|
+
|
|
38
|
+
return formatSuccess({
|
|
39
|
+
summary: `Dry run: ${operations.length} operation(s) validated locally. ${preview.requests.length} request(s) would be sent — no API calls were made (LinkedIn has no server-side validate-only mode). Re-run with dry_run=false to execute.`,
|
|
40
|
+
data: preview.requests,
|
|
41
|
+
metadata: {
|
|
42
|
+
dryRun: true,
|
|
43
|
+
serverValidated: false,
|
|
44
|
+
operationCount: operations.length,
|
|
45
|
+
apiCallCount: preview.requests.length,
|
|
46
|
+
accountId
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (process.env.LINKEDIN_ADS_READ_ONLY === 'true') {
|
|
52
|
+
throw new Error('mutate is disabled in read-only mode (LINKEDIN_ADS_READ_ONLY=true)');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const results = [];
|
|
56
|
+
let succeeded = 0;
|
|
57
|
+
let failed = 0;
|
|
58
|
+
|
|
59
|
+
for (let index = 0; index < operations.length; index += 1) {
|
|
60
|
+
const operation = operations[index];
|
|
61
|
+
const apiRequest = buildApiRequest(operation, accountId);
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
const response = await request(apiRequest.path, {}, {
|
|
65
|
+
method: apiRequest.method,
|
|
66
|
+
headers: apiRequest.headers,
|
|
67
|
+
body: apiRequest.body
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const resolvedId = response?.restliId || operation.id || null;
|
|
71
|
+
results.push({
|
|
72
|
+
index,
|
|
73
|
+
entity: operation.entity,
|
|
74
|
+
action: operation.action,
|
|
75
|
+
success: true,
|
|
76
|
+
id: resolvedId,
|
|
77
|
+
response
|
|
78
|
+
});
|
|
79
|
+
succeeded += 1;
|
|
80
|
+
} catch (error) {
|
|
81
|
+
results.push({
|
|
82
|
+
index,
|
|
83
|
+
entity: operation.entity,
|
|
84
|
+
action: operation.action,
|
|
85
|
+
success: false,
|
|
86
|
+
error: {
|
|
87
|
+
message: error.message
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
failed += 1;
|
|
91
|
+
|
|
92
|
+
if (!partialFailure) {
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (succeeded === 0 && failed > 0) {
|
|
99
|
+
const firstError = results.find((entry) => !entry.success)?.error?.message || 'Unknown';
|
|
100
|
+
throw new Error(`All ${failed} operation(s) failed. First error: ${firstError}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return formatSuccess({
|
|
104
|
+
summary: `Executed ${results.length} operation(s): ${succeeded} succeeded, ${failed} failed`,
|
|
105
|
+
data: results,
|
|
106
|
+
metadata: {
|
|
107
|
+
dryRun: false,
|
|
108
|
+
operationCount: results.length,
|
|
109
|
+
succeeded,
|
|
110
|
+
failed,
|
|
111
|
+
accountId
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
} catch (error) {
|
|
115
|
+
return formatError(error);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { linkedinRequest } from '../http.js';
|
|
2
|
+
import { formatError, formatSuccess } from '../utils/response-format.js';
|
|
3
|
+
import { rawParam, restliSearchFilter } from '../utils/restli.js';
|
|
4
|
+
import { getAccountId, toEntityUrn, validateEnum } from '../utils/validation.js';
|
|
5
|
+
|
|
6
|
+
const ENTITY_CONFIG = {
|
|
7
|
+
campaigns: {
|
|
8
|
+
collection: 'adCampaigns',
|
|
9
|
+
finder: 'search',
|
|
10
|
+
maxPageSize: 1000,
|
|
11
|
+
statuses: ['ACTIVE', 'PAUSED', 'ARCHIVED', 'COMPLETED', 'CANCELED', 'DRAFT', 'PENDING_DELETION', 'REMOVED']
|
|
12
|
+
},
|
|
13
|
+
campaign_groups: {
|
|
14
|
+
collection: 'adCampaignGroups',
|
|
15
|
+
finder: 'search',
|
|
16
|
+
maxPageSize: 1000,
|
|
17
|
+
statuses: ['ACTIVE', 'PAUSED', 'ARCHIVED', 'CANCELED', 'DRAFT', 'PENDING_DELETION', 'REMOVED']
|
|
18
|
+
},
|
|
19
|
+
creatives: {
|
|
20
|
+
collection: 'creatives',
|
|
21
|
+
finder: 'criteria',
|
|
22
|
+
maxPageSize: 100,
|
|
23
|
+
statuses: ['ACTIVE', 'PAUSED', 'DRAFT', 'ARCHIVED', 'CANCELED', 'PENDING_DELETION', 'REMOVED']
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const SUPPORTED_QUERY_ENTITIES = Object.keys(ENTITY_CONFIG);
|
|
28
|
+
|
|
29
|
+
const DEFAULT_LIMIT = 100;
|
|
30
|
+
|
|
31
|
+
function normalizeStatusValues(value, config) {
|
|
32
|
+
if (value === undefined || value === null || value === '') {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const values = (Array.isArray(value) ? value : String(value).split(','))
|
|
37
|
+
.map((entry) => String(entry).trim().toUpperCase())
|
|
38
|
+
.filter(Boolean);
|
|
39
|
+
|
|
40
|
+
for (const entry of values) {
|
|
41
|
+
validateEnum(entry, config.statuses, 'status');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return values;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function getRequestedLimit(limitValue) {
|
|
48
|
+
const parsed = Number(limitValue);
|
|
49
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
50
|
+
return DEFAULT_LIMIT;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return Math.floor(parsed);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function buildBaseParams(entity, statusValues, params) {
|
|
57
|
+
const config = ENTITY_CONFIG[entity];
|
|
58
|
+
const queryParams = {
|
|
59
|
+
q: config.finder
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
if (config.finder === 'search') {
|
|
63
|
+
const searchFilter = restliSearchFilter({ status: statusValues });
|
|
64
|
+
if (searchFilter) {
|
|
65
|
+
queryParams.search = rawParam(searchFilter);
|
|
66
|
+
}
|
|
67
|
+
return queryParams;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// creatives use the criteria finder with top-level List params
|
|
71
|
+
if (statusValues.length > 0) {
|
|
72
|
+
queryParams.intendedStatuses = statusValues;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (Array.isArray(params.campaign_ids) && params.campaign_ids.length > 0) {
|
|
76
|
+
queryParams.campaigns = params.campaign_ids.map((id) => toEntityUrn('campaign', id));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return queryParams;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function fetchAllPages(request, path, baseParams, requestedLimit, maxPageSize) {
|
|
83
|
+
const rows = [];
|
|
84
|
+
let pageToken = null;
|
|
85
|
+
|
|
86
|
+
while (rows.length < requestedLimit) {
|
|
87
|
+
const pageParams = {
|
|
88
|
+
...baseParams,
|
|
89
|
+
pageSize: Math.min(requestedLimit - rows.length, maxPageSize)
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
if (pageToken) {
|
|
93
|
+
pageParams.pageToken = pageToken;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const response = await request(path, pageParams);
|
|
97
|
+
const elements = Array.isArray(response?.elements) ? response.elements : [];
|
|
98
|
+
rows.push(...elements);
|
|
99
|
+
|
|
100
|
+
pageToken = response?.metadata?.nextPageToken;
|
|
101
|
+
if (!pageToken || elements.length === 0) {
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return rows.slice(0, requestedLimit);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Query campaigns, campaign groups, or creatives for a LinkedIn ad account
|
|
111
|
+
* using the entity finder appropriate for each collection (q=search for
|
|
112
|
+
* campaigns/campaign groups, q=criteria for creatives) with cursor pagination.
|
|
113
|
+
* @param {Record<string, unknown>} [params]
|
|
114
|
+
* @param {{ request?: (path: string, params: Record<string, unknown>) => Promise<any> }} [dependencies]
|
|
115
|
+
* @returns {Promise<import('@modelcontextprotocol/sdk/types.js').CallToolResult>}
|
|
116
|
+
*/
|
|
117
|
+
export async function query(params = {}, dependencies = {}) {
|
|
118
|
+
const request = dependencies.request || linkedinRequest;
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
validateEnum(params.entity, SUPPORTED_QUERY_ENTITIES, 'entity');
|
|
122
|
+
|
|
123
|
+
const entity = params.entity;
|
|
124
|
+
const config = ENTITY_CONFIG[entity];
|
|
125
|
+
const accountId = getAccountId(params);
|
|
126
|
+
const statusValues = normalizeStatusValues(params.status, config);
|
|
127
|
+
const requestedLimit = getRequestedLimit(params.limit);
|
|
128
|
+
const path = `/adAccounts/${accountId}/${config.collection}`;
|
|
129
|
+
|
|
130
|
+
const baseParams = buildBaseParams(entity, statusValues, params);
|
|
131
|
+
const rows = await fetchAllPages(request, path, baseParams, requestedLimit, config.maxPageSize);
|
|
132
|
+
|
|
133
|
+
return formatSuccess({
|
|
134
|
+
summary: `Returned ${rows.length} ${entity} row${rows.length === 1 ? '' : 's'}`,
|
|
135
|
+
data: rows,
|
|
136
|
+
metadata: {
|
|
137
|
+
entity,
|
|
138
|
+
accountId,
|
|
139
|
+
limit: requestedLimit,
|
|
140
|
+
returned: rows.length,
|
|
141
|
+
appliedStatusFilter: statusValues.length > 0 ? statusValues : null
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
} catch (error) {
|
|
145
|
+
return formatError(error);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
function createTypedError(code, message) {
|
|
2
|
+
const error = new Error(message);
|
|
3
|
+
error.mcpCode = code;
|
|
4
|
+
return error;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Create an InvalidParams typed error.
|
|
9
|
+
*/
|
|
10
|
+
export function invalidParamsError(message) {
|
|
11
|
+
return createTypedError('InvalidParams', message);
|
|
12
|
+
}
|