@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.
@@ -0,0 +1,211 @@
1
+ import { getApiVersion, RESTLI_PROTOCOL_VERSION } from '../http.js';
2
+ import { encodeRestliValue } from './restli.js';
3
+ import { fromEntityUrn, toEntityUrn, validateArray } from './validation.js';
4
+
5
+ /**
6
+ * Supported mutate entity identifiers.
7
+ */
8
+ export const SUPPORTED_MUTATE_ENTITIES = ['campaign', 'campaign_group', 'creative'];
9
+ /**
10
+ * Supported mutate action identifiers.
11
+ */
12
+ export const SUPPORTED_MUTATE_ACTIONS = ['create', 'update', 'pause', 'enable', 'archive'];
13
+
14
+ /**
15
+ * Default status applied to created entities. LinkedIn documents DRAFT as the
16
+ * safe non-serving state for newly created campaigns, campaign groups, and
17
+ * creatives; pass an explicit status (intendedStatus for creatives) to override.
18
+ */
19
+ export const DEFAULT_CREATE_STATUS = 'DRAFT';
20
+
21
+ const ENTITY_COLLECTIONS = {
22
+ campaign: 'adCampaigns',
23
+ campaign_group: 'adCampaignGroups',
24
+ creative: 'creatives'
25
+ };
26
+
27
+ const STATUS_FIELD_BY_ENTITY = {
28
+ campaign: 'status',
29
+ campaign_group: 'status',
30
+ creative: 'intendedStatus'
31
+ };
32
+
33
+ const STATUS_BY_ACTION = {
34
+ pause: 'PAUSED',
35
+ enable: 'ACTIVE',
36
+ archive: 'ARCHIVED'
37
+ };
38
+
39
+ function validateOperationShape(op, index) {
40
+ if (!op || typeof op !== 'object' || Array.isArray(op)) {
41
+ return { index, message: 'Operation must be an object' };
42
+ }
43
+
44
+ if (!op.entity) {
45
+ return { index, message: 'Missing required field: entity' };
46
+ }
47
+
48
+ if (!SUPPORTED_MUTATE_ENTITIES.includes(op.entity)) {
49
+ return {
50
+ index,
51
+ message: `Unsupported entity: ${op.entity}. Supported: ${SUPPORTED_MUTATE_ENTITIES.join(', ')}`
52
+ };
53
+ }
54
+
55
+ if (!op.action) {
56
+ return { index, message: 'Missing required field: action' };
57
+ }
58
+
59
+ if (!SUPPORTED_MUTATE_ACTIONS.includes(op.action)) {
60
+ return {
61
+ index,
62
+ message: `Unsupported action: ${op.action}. Supported: ${SUPPORTED_MUTATE_ACTIONS.join(', ')}`
63
+ };
64
+ }
65
+
66
+ if ((op.action === 'create' || op.action === 'update') && (!op.params || typeof op.params !== 'object' || Array.isArray(op.params))) {
67
+ return {
68
+ index,
69
+ message: `${op.action} requires params object`
70
+ };
71
+ }
72
+
73
+ if (op.action !== 'create' && !op.id) {
74
+ return {
75
+ index,
76
+ message: `${op.action} requires id`
77
+ };
78
+ }
79
+
80
+ if (op.entity === 'creative' && op.action === 'create' && !op.params?.campaign) {
81
+ return {
82
+ index,
83
+ message: 'creative create requires params.campaign (campaign ID or urn:li:sponsoredCampaign URN)'
84
+ };
85
+ }
86
+
87
+ return null;
88
+ }
89
+
90
+ /**
91
+ * Validate mutate operation array shape and action/entity compatibility.
92
+ * @param {unknown} operations
93
+ * @returns {Array<{ index: number, message: string }>}
94
+ */
95
+ export function validateOperations(operations) {
96
+ validateArray(operations, 'operations');
97
+
98
+ const errors = [];
99
+ for (let index = 0; index < operations.length; index += 1) {
100
+ const issue = validateOperationShape(operations[index], index);
101
+ if (issue) {
102
+ errors.push(issue);
103
+ }
104
+ }
105
+
106
+ return errors;
107
+ }
108
+
109
+ // Creatives are keyed by URN in resource paths and must be percent-encoded;
110
+ // campaigns and campaign groups are keyed by their numeric ID.
111
+ function encodeEntityKey(entity, id) {
112
+ if (entity === 'creative') {
113
+ return encodeRestliValue(toEntityUrn('creative', id));
114
+ }
115
+
116
+ return encodeRestliValue(fromEntityUrn(id));
117
+ }
118
+
119
+ function buildCreateBody(operation, accountId) {
120
+ const statusField = STATUS_FIELD_BY_ENTITY[operation.entity];
121
+ const body = {
122
+ [statusField]: DEFAULT_CREATE_STATUS,
123
+ ...operation.params
124
+ };
125
+
126
+ if (operation.entity === 'creative') {
127
+ body.campaign = toEntityUrn('campaign', operation.params.campaign);
128
+ return body;
129
+ }
130
+
131
+ if (!body.account) {
132
+ body.account = toEntityUrn('account', accountId);
133
+ }
134
+
135
+ return body;
136
+ }
137
+
138
+ /**
139
+ * Build a LinkedIn Marketing API request from a single mutate operation.
140
+ * Creates POST to the entity collection under the account; update and status
141
+ * changes POST to the entity item with X-RestLi-Method: PARTIAL_UPDATE and a
142
+ * { patch: { $set: {...} } } body.
143
+ * @param {{ entity: string, action: string, id?: string, params?: Record<string, unknown> }} operation
144
+ * @param {string} accountId
145
+ * @returns {{ method: string, path: string, headers: Record<string, string>, body: Record<string, unknown> }}
146
+ */
147
+ export function buildApiRequest(operation, accountId) {
148
+ const collection = ENTITY_COLLECTIONS[operation.entity];
149
+
150
+ if (operation.action === 'create') {
151
+ return {
152
+ method: 'POST',
153
+ path: `/adAccounts/${accountId}/${collection}`,
154
+ headers: {},
155
+ body: buildCreateBody(operation, accountId)
156
+ };
157
+ }
158
+
159
+ const statusField = STATUS_FIELD_BY_ENTITY[operation.entity];
160
+ const actionStatus = STATUS_BY_ACTION[operation.action];
161
+ const setPayload = actionStatus
162
+ ? { [statusField]: actionStatus, ...(operation.params || {}) }
163
+ : { ...(operation.params || {}) };
164
+
165
+ return {
166
+ method: 'POST',
167
+ path: `/adAccounts/${accountId}/${collection}/${encodeEntityKey(operation.entity, operation.id)}`,
168
+ headers: {
169
+ 'X-RestLi-Method': 'PARTIAL_UPDATE'
170
+ },
171
+ body: {
172
+ patch: {
173
+ $set: setPayload
174
+ }
175
+ }
176
+ };
177
+ }
178
+
179
+ /**
180
+ * Build a human-readable preview of the exact API requests derived from
181
+ * operations, including the versioned headers that would be sent
182
+ * (Authorization is omitted).
183
+ * @param {Array<{ entity: string, action: string, id?: string, params?: Record<string, unknown> }>} operations
184
+ * @param {string} accountId
185
+ * @returns {{ requests: Array<Record<string, unknown>> }}
186
+ */
187
+ export function buildRequestPreview(operations, accountId) {
188
+ const sharedHeaders = {
189
+ 'LinkedIn-Version': getApiVersion(),
190
+ 'X-Restli-Protocol-Version': RESTLI_PROTOCOL_VERSION,
191
+ 'Content-Type': 'application/json'
192
+ };
193
+
194
+ return {
195
+ requests: operations.map((operation, index) => {
196
+ const request = buildApiRequest(operation, accountId);
197
+ return {
198
+ index,
199
+ entity: operation.entity,
200
+ action: operation.action,
201
+ method: request.method,
202
+ path: request.path,
203
+ headers: {
204
+ ...sharedHeaders,
205
+ ...request.headers
206
+ },
207
+ body: request.body
208
+ };
209
+ })
210
+ };
211
+ }
@@ -0,0 +1,63 @@
1
+ let SdkMcpError = null;
2
+ let SdkErrorCode = null;
3
+
4
+ try {
5
+ const sdkTypes = await import('@modelcontextprotocol/sdk/types.js');
6
+ SdkMcpError = sdkTypes.McpError;
7
+ SdkErrorCode = sdkTypes.ErrorCode;
8
+ } catch {
9
+ // SDK may be absent in minimal test environments.
10
+ }
11
+
12
+ /**
13
+ * Format a successful tool result payload in MCP text content structure.
14
+ * @param {{ summary: string, data: any, metadata?: Record<string, unknown> }} payload
15
+ * @returns {import('@modelcontextprotocol/sdk/types.js').CallToolResult}
16
+ */
17
+ export function formatSuccess({ summary, data, metadata = {} }) {
18
+ return {
19
+ content: [{
20
+ type: 'text',
21
+ text: JSON.stringify({
22
+ success: true,
23
+ summary,
24
+ data,
25
+ metadata: {
26
+ rowCount: Array.isArray(data) ? data.length : undefined,
27
+ warnings: [],
28
+ ...metadata
29
+ }
30
+ }, null, 2)
31
+ }]
32
+ };
33
+ }
34
+
35
+ function createMcpStyleError(code, message) {
36
+ if (SdkMcpError && SdkErrorCode && SdkErrorCode[code]) {
37
+ return new SdkMcpError(SdkErrorCode[code], message);
38
+ }
39
+
40
+ const error = new Error(message);
41
+ error.code = code;
42
+ return error;
43
+ }
44
+
45
+ /**
46
+ * Convert thrown errors into MCP-compatible typed errors.
47
+ * @param {unknown} error
48
+ * @throws {Error}
49
+ */
50
+ export function formatError(error) {
51
+ const message = error?.message || String(error) || 'Unknown error';
52
+ const explicitCode = error?.mcpCode === 'InvalidParams' || error?.code === 'InvalidParams'
53
+ ? 'InvalidParams'
54
+ : error?.mcpCode === 'InternalError' || error?.code === 'InternalError'
55
+ ? 'InternalError'
56
+ : null;
57
+
58
+ if (explicitCode) {
59
+ throw createMcpStyleError(explicitCode, message);
60
+ }
61
+
62
+ throw createMcpStyleError('InternalError', message);
63
+ }
@@ -0,0 +1,153 @@
1
+ import { invalidParamsError } from './errors.js';
2
+
3
+ class RestliRaw {
4
+ constructor(value) {
5
+ this.value = String(value);
6
+ this.__restliRaw = true;
7
+ }
8
+ }
9
+
10
+ /**
11
+ * Wrap a pre-built Rest.li expression so buildQueryString inserts it verbatim.
12
+ * Use for structured params like dateRange/search whose parens, colons, and
13
+ * commas must stay literal in the query string.
14
+ * @param {string} value
15
+ * @returns {RestliRaw}
16
+ */
17
+ export function rawParam(value) {
18
+ return new RestliRaw(value);
19
+ }
20
+
21
+ /**
22
+ * Check whether a value was produced by rawParam(). Duck-typed rather than
23
+ * instanceof so values survive module re-imports (e.g. cache-busted test loads).
24
+ */
25
+ export function isRawParam(value) {
26
+ return Boolean(value)
27
+ && typeof value === 'object'
28
+ && value.__restliRaw === true
29
+ && typeof value.value === 'string';
30
+ }
31
+
32
+ /**
33
+ * Percent-encode a single Rest.li value. Extends encodeURIComponent to also
34
+ * encode characters Rest.li treats as structural but JS leaves bare: ! ' ( ) *
35
+ * URNs become e.g. urn%3Ali%3AsponsoredCampaign%3A123.
36
+ * @param {unknown} value
37
+ * @returns {string}
38
+ */
39
+ export function encodeRestliValue(value) {
40
+ return encodeURIComponent(String(value)).replace(
41
+ /[!'()*]/g,
42
+ (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`
43
+ );
44
+ }
45
+
46
+ /**
47
+ * Build a Rest.li List(...) expression with each item percent-encoded and
48
+ * item separators kept as literal commas.
49
+ * @param {unknown[] | unknown} values
50
+ * @returns {string}
51
+ */
52
+ export function restliList(values) {
53
+ const items = Array.isArray(values) ? values : [values];
54
+ return `List(${items.map(encodeRestliValue).join(',')})`;
55
+ }
56
+
57
+ function parseIsoDate(value, label) {
58
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(value ?? '').trim());
59
+ if (!match) {
60
+ throw invalidParamsError(`Invalid ${label} date format: ${value}. Expected YYYY-MM-DD`);
61
+ }
62
+
63
+ const year = Number(match[1]);
64
+ const month = Number(match[2]);
65
+ const day = Number(match[3]);
66
+
67
+ if (month < 1 || month > 12 || day < 1 || day > 31) {
68
+ throw invalidParamsError(`Invalid ${label} date value: ${value}`);
69
+ }
70
+
71
+ return { year, month, day };
72
+ }
73
+
74
+ /**
75
+ * Convert YYYY-MM-DD to a Rest.li date expression: (year:2026,month:6,day:1).
76
+ * Month and day are emitted without leading zeros as LinkedIn expects.
77
+ * @param {string} value
78
+ * @param {string} [label]
79
+ * @returns {string}
80
+ */
81
+ export function restliDate(value, label = 'date') {
82
+ const { year, month, day } = parseIsoDate(value, label);
83
+ return `(year:${year},month:${month},day:${day})`;
84
+ }
85
+
86
+ /**
87
+ * Build the adAnalytics dateRange expression from ISO dates. End is optional
88
+ * (LinkedIn treats a missing end as "through today").
89
+ * @param {string} start
90
+ * @param {string} [end]
91
+ * @returns {string}
92
+ */
93
+ export function restliDateRange(start, end) {
94
+ const parts = [`start:${restliDate(start, 'start')}`];
95
+ if (end !== undefined && end !== null && end !== '') {
96
+ parts.push(`end:${restliDate(end, 'end')}`);
97
+ }
98
+ return `(${parts.join(',')})`;
99
+ }
100
+
101
+ /**
102
+ * Build a finder search expression: (status:(values:List(ACTIVE,PAUSED)),...).
103
+ * Fields with empty value arrays are omitted; returns null when no clause
104
+ * remains so callers can skip the param entirely.
105
+ * @param {Record<string, unknown[]>} filters
106
+ * @returns {string | null}
107
+ */
108
+ export function restliSearchFilter(filters = {}) {
109
+ const clauses = Object.entries(filters)
110
+ .filter(([, values]) => Array.isArray(values) && values.length > 0)
111
+ .map(([field, values]) => `${field}:(values:${restliList(values)})`);
112
+
113
+ if (clauses.length === 0) {
114
+ return null;
115
+ }
116
+
117
+ return `(${clauses.join(',')})`;
118
+ }
119
+
120
+ /**
121
+ * Build a Rest.li 2.0-safe query string. Never uses URLSearchParams because
122
+ * that would percent-encode the structural parens/colons/commas of List(...)
123
+ * and dateRange expressions. Handling by value type:
124
+ * - rawParam(...) values are inserted verbatim
125
+ * - arrays become List(...) with percent-encoded items
126
+ * - everything else is percent-encoded as a single value
127
+ * Null/undefined/empty-string values are skipped.
128
+ * @param {Record<string, unknown>} [params]
129
+ * @returns {string}
130
+ */
131
+ export function buildQueryString(params = {}) {
132
+ const parts = [];
133
+
134
+ for (const [key, value] of Object.entries(params)) {
135
+ if (value === undefined || value === null || value === '') {
136
+ continue;
137
+ }
138
+
139
+ if (isRawParam(value)) {
140
+ parts.push(`${key}=${value.value}`);
141
+ continue;
142
+ }
143
+
144
+ if (Array.isArray(value)) {
145
+ parts.push(`${key}=${restliList(value)}`);
146
+ continue;
147
+ }
148
+
149
+ parts.push(`${key}=${encodeRestliValue(value)}`);
150
+ }
151
+
152
+ return parts.join('&');
153
+ }
@@ -0,0 +1,104 @@
1
+ import { invalidParamsError } from './errors.js';
2
+
3
+ /**
4
+ * Validate that required fields are present on the params object.
5
+ */
6
+ export function validateRequired(params, fields) {
7
+ const missing = fields.filter((field) => {
8
+ const value = params[field];
9
+ return value === undefined || value === null || value === '';
10
+ });
11
+
12
+ if (missing.length > 0) {
13
+ throw invalidParamsError(`Missing required parameter${missing.length > 1 ? 's' : ''}: ${missing.join(', ')}`);
14
+ }
15
+ }
16
+
17
+ /**
18
+ * Validate an enum value against an allowed set.
19
+ */
20
+ export function validateEnum(value, allowed, paramName = 'value') {
21
+ if (!allowed.includes(value)) {
22
+ throw invalidParamsError(`Invalid ${paramName}: ${value}. Allowed values: ${allowed.join(', ')}`);
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Validate that a value is a non-empty array.
28
+ */
29
+ export function validateArray(value, paramName = 'value') {
30
+ if (!Array.isArray(value) || value.length === 0) {
31
+ throw invalidParamsError(`${paramName} must be a non-empty array`);
32
+ }
33
+ }
34
+
35
+ /**
36
+ * URN prefixes for the sponsored entity types this server works with.
37
+ */
38
+ export const ENTITY_URN_PREFIXES = {
39
+ account: 'urn:li:sponsoredAccount:',
40
+ campaign: 'urn:li:sponsoredCampaign:',
41
+ campaign_group: 'urn:li:sponsoredCampaignGroup:',
42
+ creative: 'urn:li:sponsoredCreative:'
43
+ };
44
+
45
+ /**
46
+ * Normalize ad account IDs by stripping an optional sponsoredAccount URN prefix.
47
+ */
48
+ export function normalizeAccountId(accountId) {
49
+ const raw = String(accountId || '').trim();
50
+ if (!raw) {
51
+ return '';
52
+ }
53
+
54
+ return raw.startsWith(ENTITY_URN_PREFIXES.account)
55
+ ? raw.slice(ENTITY_URN_PREFIXES.account.length)
56
+ : raw;
57
+ }
58
+
59
+ /**
60
+ * Resolve account ID from params or environment, normalized to the plain numeric form.
61
+ */
62
+ export function getAccountId(params = {}) {
63
+ const accountId = params.account_id || process.env.LINKEDIN_ADS_ACCOUNT_ID;
64
+ if (!accountId) {
65
+ throw invalidParamsError('account_id parameter or LINKEDIN_ADS_ACCOUNT_ID environment variable required');
66
+ }
67
+
68
+ return normalizeAccountId(accountId);
69
+ }
70
+
71
+ /**
72
+ * Convert a plain ID to a fully-qualified sponsored entity URN.
73
+ * IDs that already look like URNs are passed through untouched.
74
+ * @param {'account' | 'campaign' | 'campaign_group' | 'creative'} entityType
75
+ * @param {string | number} id
76
+ * @returns {string}
77
+ */
78
+ export function toEntityUrn(entityType, id) {
79
+ const prefix = ENTITY_URN_PREFIXES[entityType];
80
+ if (!prefix) {
81
+ throw invalidParamsError(`Unknown entity type for URN: ${entityType}. Allowed: ${Object.keys(ENTITY_URN_PREFIXES).join(', ')}`);
82
+ }
83
+
84
+ const raw = String(id ?? '').trim();
85
+ if (!raw) {
86
+ throw invalidParamsError(`Missing id for ${entityType} URN`);
87
+ }
88
+
89
+ return raw.startsWith('urn:li:') ? raw : `${prefix}${raw}`;
90
+ }
91
+
92
+ /**
93
+ * Extract the trailing entity ID from a URN, or return the value unchanged
94
+ * when it is already a plain ID.
95
+ */
96
+ export function fromEntityUrn(value) {
97
+ const raw = String(value ?? '').trim();
98
+ if (!raw.startsWith('urn:li:')) {
99
+ return raw;
100
+ }
101
+
102
+ const segments = raw.split(':');
103
+ return segments[segments.length - 1];
104
+ }