diff --git a/.changeset/consent-triage-filters.md b/.changeset/consent-triage-filters.md new file mode 100644 index 000000000..ad5a40c9f --- /dev/null +++ b/.changeset/consent-triage-filters.md @@ -0,0 +1,6 @@ +--- +"@transcend-io/mcp-server-consent": minor +"@transcend-io/privacy-types": patch +--- + +Add consent triage filters and sorting: `unmappedOnly` (orphaned/unmapped approved data flows), `type` (data flow scope, e.g. CSP), and `minOccurrences` on `consent_list_data_flows`; `minOccurrences` and `occurrences` sorting on `consent_list_cookies`. Clarify `showZeroActivity` semantics so the default `NEEDS_REVIEW` totals reconcile with `consent_get_inventory_stats`. diff --git a/.changeset/consent-typed-graphql-codegen.md b/.changeset/consent-typed-graphql-codegen.md new file mode 100644 index 000000000..bcc310230 --- /dev/null +++ b/.changeset/consent-typed-graphql-codegen.md @@ -0,0 +1,5 @@ +--- +"@transcend-io/mcp-server-consent": patch +--- + +Migrate all consent MCP GraphQL operations to the typed `graphql()` codegen path (matching the other MCP servers) instead of importing plain `gql` strings and hand-written response types from `@transcend-io/sdk`. Operations now live in `src/graphql.ts` and are validated against the committed `schema.graphql` at compile time, so schema drift fails `tsc` rather than surfacing as a runtime error. No change to tool behavior. diff --git a/codegen.ts b/codegen.ts index 9bd218dce..08c4c90b6 100644 --- a/codegen.ts +++ b/codegen.ts @@ -18,12 +18,20 @@ const SCHEMA_PATH = './schema.graphql'; /** * GraphQL-backed MCP servers. Each entry corresponds to a package directory * `packages/mcp/mcp-server-${name}` containing a `src/graphql.ts` file with - * embedded operations. The `mcp-server-base`, `mcp-server-consent`, and - * `mcp-server-preferences` packages are intentionally excluded: + * embedded operations. The `mcp-server-base` and `mcp-server-preferences` + * packages are intentionally excluded: * - base has no GraphQL operations of its own. - * - consent/preferences hit REST endpoints, not GraphQL. + * - preferences hits REST endpoints, not GraphQL. */ -const SERVERS = ['admin', 'assessment', 'discovery', 'dsr', 'inventory', 'workflows'] as const; +const SERVERS = [ + 'admin', + 'assessment', + 'consent', + 'discovery', + 'dsr', + 'inventory', + 'workflows', +] as const; const config: CodegenConfig = { schema: SCHEMA_PATH, diff --git a/packages/mcp/mcp-server-consent/package.json b/packages/mcp/mcp-server-consent/package.json index ca938c20d..156a5fd4a 100644 --- a/packages/mcp/mcp-server-consent/package.json +++ b/packages/mcp/mcp-server-consent/package.json @@ -37,10 +37,11 @@ "check:publint": "publint --level warning --strict --pack pnpm" }, "dependencies": { + "@graphql-typed-document-node/core": "catalog:", "@modelcontextprotocol/sdk": "catalog:", "@transcend-io/mcp-server-base": "workspace:*", "@transcend-io/privacy-types": "workspace:*", - "@transcend-io/sdk": "workspace:*", + "graphql": "catalog:", "zod": "catalog:" }, "devDependencies": { diff --git a/packages/mcp/mcp-server-consent/src/graphql.ts b/packages/mcp/mcp-server-consent/src/graphql.ts new file mode 100644 index 000000000..f3c5447cd --- /dev/null +++ b/packages/mcp/mcp-server-consent/src/graphql.ts @@ -0,0 +1,374 @@ +/** + * GraphQL operations for the Consent Management MCP server. + * + * Operations are authored with the generated `graphql()` tag so they are + * validated against the committed `schema.graphql` at compile time — drift + * between an operation (or the variables/enums we send) and the schema fails + * `tsc` rather than surfacing as a runtime error. `TranscendGraphQLBase.makeRequest` + * consumes the returned `TypedDocumentNode` natively. + * + * Run `pnpm codegen` after editing to regenerate `./__generated__/`. + */ +import { graphql } from './__generated__/gql.js'; + +// Input types (generated from the schema) used to build mutation variables. +export type { UpdateOrCreateCookieInput, UpdateDataFlowInput } from './__generated__/graphql.js'; + +export const FetchConsentManagerIdDoc = graphql(/* GraphQL */ ` + query TranscendCliFetchConsentManagerId { + consentManager { + consentManager { + id + } + } + } +`); + +export const FetchConsentManagerDoc = graphql(/* GraphQL */ ` + query TranscendCliFetchConsentManager { + consentManager { + consentManager { + id + bundleURL + testBundleURL + configuration { + domains + consentPrecedence + unknownRequestPolicy + unknownCookiePolicy + syncEndpoint + telemetryPartitioning + signedIabAgreement + syncGroups + partition + } + partition { + partition + } + } + } + } +`); + +export const CookiesDoc = graphql(/* GraphQL */ ` + query TranscendCliCookies( + $input: AirgapBundleInput! + $first: Int! + $offset: Int! + $filterBy: CookiesFiltersInput + $orderBy: [CookieOrder!] + ) { + cookies( + input: $input + first: $first + offset: $offset + filterBy: $filterBy + orderBy: $orderBy + useMaster: false + ) { + totalCount + nodes { + id + name + isRegex + description + trackingPurposes + purposes { + id + name + trackingType + } + frequency + service { + id + title + integrationName + } + isJunk + source + status + owners { + id + name + email + } + teams { + id + name + } + attributeValues { + id + name + attributeKey { + id + name + } + } + createdAt + updatedAt + lastDiscoveredAt + domains { + id + domain + occurrences + } + occurrences + consentSiteCountAllTime + consentSiteCountLastWeek + } + } + } +`); + +export const DataFlowsDoc = graphql(/* GraphQL */ ` + query TranscendCliDataFlows( + $input: AirgapBundleInput! + $first: Int! + $offset: Int! + $filterBy: DataFlowsFiltersInput + $orderBy: [DataFlowOrder!] + ) { + dataFlows( + input: $input + first: $first + offset: $offset + filterBy: $filterBy + orderBy: $orderBy + useMaster: false + ) { + totalCount + nodes { + id + value + type + description + trackingType + purposes { + id + name + trackingType + } + frequency + service { + id + title + integrationName + } + isJunk + source + status + owners { + id + name + email + } + teams { + id + name + } + attributeValues { + id + name + attributeKey { + id + name + } + } + createdAt + updatedAt + lastDiscoveredAt + occurrences + consentSiteCountAllTime + consentSiteCountLastWeek + } + } + } +`); + +export const CookieStatsDoc = graphql(/* GraphQL */ ` + query TranscendCliCookieStats($input: AirgapBundleInput!) { + cookieStats(input: $input) { + liveCount + needReviewCount + junkCount + } + } +`); + +export const DataFlowStatsDoc = graphql(/* GraphQL */ ` + query TranscendCliDataFlowStats($input: AirgapBundleInput!) { + dataFlowStats(input: $input) { + liveCount + needReviewCount + junkCount + } + } +`); + +export const PurposesDoc = graphql(/* GraphQL */ ` + query TranscendCliPurposes($first: Int!) { + purposes(first: $first) { + nodes { + id + name + description + defaultConsent + trackingType + configurable + essential + showInConsentManager + isActive + displayOrder + optOutSignals + deletedAt + authLevel + showInPrivacyCenter + title + preferenceTopics { + id + slug + title { + description + } + color + } + purposeDataPointCount + preferenceTopicOptionValueDataPointCount + mappedDataSilos { + id + title + type + } + } + totalCount + } + } +`); + +export const ExperiencesDoc = graphql(/* GraphQL */ ` + query TranscendCliExperiences($first: Int!, $offset: Int!) { + experiences(first: $first, offset: $offset, useMaster: false) { + totalCount + nodes { + id + name + displayName + regions { + countrySubDivision + country + } + operator + displayPriority + onConsentExpiry + consentExpiry + viewState + purposes { + name + trackingType + } + optedOutPurposes { + name + trackingType + } + browserLanguages + browserTimeZones + consentUiVariant { + id + slug + } + } + } + } +`); + +export const UpdateOrCreateCookiesDoc = graphql(/* GraphQL */ ` + mutation TranscendCliUpdateOrCreateCookies( + $cookies: [UpdateOrCreateCookieInput!]! + $airgapBundleId: ID! + ) { + updateOrCreateCookies(input: { airgapBundleId: $airgapBundleId, cookies: $cookies }) { + clientMutationId + } + } +`); + +export const UpdateDataFlowsDoc = graphql(/* GraphQL */ ` + mutation TranscendCliUpdateDataFlows($airgapBundleId: ID!, $dataFlows: [UpdateDataFlowInput!]!) { + updateDataFlows(input: { airgapBundleId: $airgapBundleId, dataFlows: $dataFlows }) { + dataFlows { + id + value + type + description + trackingType + purposes { + id + name + trackingType + } + frequency + service { + id + title + integrationName + } + isJunk + source + status + createdAt + updatedAt + lastDiscoveredAt + occurrences + consentSiteCountAllTime + consentSiteCountLastWeek + } + } + } +`); + +export const ConsentManagerAnalyticsDataDoc = graphql(/* GraphQL */ ` + query TranscendCliConsentManagerAnalyticsData($input: AnalyticsInput!) { + analyticsData(input: $input) { + series { + name + points { + key + value + } + } + } + } +`); + +export const AggregateAnalyticsDoc = graphql(/* GraphQL */ ` + query TranscendCliAirgapBundleAggregateAnalytics( + $id: ID! + $input: AirgapBundleAggregateAnalyticsInput! + ) { + airgapBundleAggregateAnalytics(id: $id, input: $input) { + items { + measure + dimensions { + NEW_VALUE + REGIME + PURPOSE + } + } + } + } +`); + +export const TimeseriesAnalyticsDoc = graphql(/* GraphQL */ ` + query TranscendCliAirgapBundleTimeseriesAnalytics( + $id: ID! + $input: AirgapBundleTimeseriesAnalyticsInput! + ) { + airgapBundleTimeseriesAnalytics(id: $id, input: $input) { + items { + time + metric + measure + } + } + } +`); diff --git a/packages/mcp/mcp-server-consent/src/resolveAirgapBundleId.ts b/packages/mcp/mcp-server-consent/src/resolveAirgapBundleId.ts index 8b7c18d90..a8fe0a39f 100644 --- a/packages/mcp/mcp-server-consent/src/resolveAirgapBundleId.ts +++ b/packages/mcp/mcp-server-consent/src/resolveAirgapBundleId.ts @@ -1,8 +1,6 @@ import type { TranscendGraphQLBase } from '@transcend-io/mcp-server-base'; -import { - FETCH_CONSENT_MANAGER_ID, - type TranscendCliFetchConsentManagerIdResponse, -} from '@transcend-io/sdk'; + +import { FetchConsentManagerIdDoc } from './graphql.js'; const bundleIdCache = new WeakMap(); @@ -15,10 +13,7 @@ export async function resolveAirgapBundleId(graphql: TranscendGraphQLBase): Prom const cached = bundleIdCache.get(graphql); if (cached) return cached; - const data = await graphql.makeRequest( - FETCH_CONSENT_MANAGER_ID, - {}, - ); + const data = await graphql.makeRequest(FetchConsentManagerIdDoc); const id = data.consentManager.consentManager.id; bundleIdCache.set(graphql, id); diff --git a/packages/mcp/mcp-server-consent/src/tools/consent_bulk_triage.ts b/packages/mcp/mcp-server-consent/src/tools/consent_bulk_triage.ts index 44f7a74bb..ad74af94d 100644 --- a/packages/mcp/mcp-server-consent/src/tools/consent_bulk_triage.ts +++ b/packages/mcp/mcp-server-consent/src/tools/consent_bulk_triage.ts @@ -4,15 +4,13 @@ import { ConsentTrackerType, TriageAction, } from '@transcend-io/privacy-types'; -import { - UPDATE_OR_CREATE_COOKIES, - UPDATE_DATA_FLOWS, - type TranscendUpdateCookieInputGql, - type TranscendUpdateDataFlowInputGql, - type TranscendCliUpdateOrCreateCookiesResponse, - type TranscendCliUpdateDataFlowsResponse, -} from '@transcend-io/sdk'; +import { + UpdateDataFlowsDoc, + UpdateOrCreateCookiesDoc, + type UpdateDataFlowInput, + type UpdateOrCreateCookieInput, +} from '../graphql.js'; import { resolveAirgapBundleId } from '../resolveAirgapBundleId.js'; export const BulkTriageItemSchema = z.object({ @@ -57,7 +55,7 @@ export function createConsentBulkTriageTool(clients: ToolClients) { } = { cookies: [], dataFlows: [] }; if (cookieItems.length > 0) { - const cookieInputs: TranscendUpdateCookieInputGql[] = cookieItems.map((item) => ({ + const cookieInputs: UpdateOrCreateCookieInput[] = cookieItems.map((item) => ({ name: item.id, ...(item.action === 'APPROVE' ? { status: ConsentTrackerStatus.Live, isJunk: false } @@ -65,13 +63,10 @@ export function createConsentBulkTriageTool(clients: ToolClients) { ...(item.trackingPurposes ? { trackingPurposes: item.trackingPurposes } : {}), ...(item.service ? { service: item.service } : {}), })); - await clients.graphql.makeRequest( - UPDATE_OR_CREATE_COOKIES, - { - airgapBundleId, - cookies: cookieInputs, - }, - ); + await clients.graphql.makeRequest(UpdateOrCreateCookiesDoc, { + airgapBundleId, + cookies: cookieInputs, + }); results.cookies = cookieInputs.map((c) => ({ name: c.name, action: c.isJunk ? 'JUNKED' : 'APPROVED', @@ -80,7 +75,7 @@ export function createConsentBulkTriageTool(clients: ToolClients) { } if (dfItems.length > 0) { - const dfInputs: TranscendUpdateDataFlowInputGql[] = dfItems.map((item) => ({ + const dfInputs: UpdateDataFlowInput[] = dfItems.map((item) => ({ id: item.id, ...(item.action === 'APPROVE' ? { status: ConsentTrackerStatus.Live, isJunk: false } @@ -88,13 +83,10 @@ export function createConsentBulkTriageTool(clients: ToolClients) { ...(item.trackingPurposes ? { purposeIds: item.trackingPurposes } : {}), ...(item.service ? { service: item.service } : {}), })); - const dfResult = await clients.graphql.makeRequest( - UPDATE_DATA_FLOWS, - { - airgapBundleId, - dataFlows: dfInputs, - }, - ); + const dfResult = await clients.graphql.makeRequest(UpdateDataFlowsDoc, { + airgapBundleId, + dataFlows: dfInputs, + }); results.dataFlows = dfResult.updateDataFlows.dataFlows.map((df) => ({ id: df.id, action: df.isJunk ? 'JUNKED' : 'APPROVED', diff --git a/packages/mcp/mcp-server-consent/src/tools/consent_get_aggregate_analytics.ts b/packages/mcp/mcp-server-consent/src/tools/consent_get_aggregate_analytics.ts index c04c2b557..b201cb029 100644 --- a/packages/mcp/mcp-server-consent/src/tools/consent_get_aggregate_analytics.ts +++ b/packages/mcp/mcp-server-consent/src/tools/consent_get_aggregate_analytics.ts @@ -1,14 +1,8 @@ import { createToolResult, defineTool, z, type ToolClients } from '@transcend-io/mcp-server-base'; -import { - AirgapBundleAnalyticsDimension, - AirgapBundleAnalyticsMetric, -} from '@transcend-io/privacy-types'; -import { - AIRGAP_BUNDLE_AGGREGATE_ANALYTICS, - type TranscendCliAirgapBundleAggregateAnalyticsResponse, -} from '@transcend-io/sdk'; +import { AirgapBundleAnalyticsDimension } from '@transcend-io/privacy-types'; import { resolveAnalyticsDateRange } from '../analyticsDateRange.js'; +import { AggregateAnalyticsDoc } from '../graphql.js'; import { airgapBundleAnalyticsMetricSchema } from '../normalizeAnalyticsMetric.js'; import { resolveAirgapBundleId } from '../resolveAirgapBundleId.js'; @@ -50,19 +44,15 @@ export function createConsentGetAggregateAnalyticsTool(clients: ToolClients) { handler: async ({ metric, start, end, days, include_dimensions }) => { const airgapBundleId = await resolveAirgapBundleId(clients.graphql); const range = resolveAnalyticsDateRange({ start, end, days }); - const data = - await clients.graphql.makeRequest( - AIRGAP_BUNDLE_AGGREGATE_ANALYTICS, - { - id: airgapBundleId, - input: { - metric, - start: range.startEpoch, - end: range.endEpoch, - ...(include_dimensions?.length ? { includeDimensions: include_dimensions } : {}), - }, - }, - ); + const data = await clients.graphql.makeRequest(AggregateAnalyticsDoc, { + id: airgapBundleId, + input: { + metric, + start: range.startEpoch, + end: range.endEpoch, + ...(include_dimensions?.length ? { includeDimensions: include_dimensions } : {}), + }, + }); const items = data.airgapBundleAggregateAnalytics.items; return createToolResult(true, { diff --git a/packages/mcp/mcp-server-consent/src/tools/consent_get_analytics_data.ts b/packages/mcp/mcp-server-consent/src/tools/consent_get_analytics_data.ts index 87b349f0e..1d9eea810 100644 --- a/packages/mcp/mcp-server-consent/src/tools/consent_get_analytics_data.ts +++ b/packages/mcp/mcp-server-consent/src/tools/consent_get_analytics_data.ts @@ -3,9 +3,9 @@ import { ConsentManagerAnalyticsDataSource, ConsentManagerMetricBin, } from '@transcend-io/privacy-types'; -import { CONSENT_MANAGER_ANALYTICS_DATA, type ConsentManagerMetric } from '@transcend-io/sdk'; import { resolveAnalyticsDateRange } from '../analyticsDateRange.js'; +import { ConsentManagerAnalyticsDataDoc } from '../graphql.js'; import { resolveAirgapBundleId } from '../resolveAirgapBundleId.js'; export const GetAnalyticsDataSchema = z.object({ @@ -48,9 +48,7 @@ export function createConsentGetAnalyticsDataTool(clients: ToolClients) { handler: async ({ data_source, start, end, days, bin }) => { const airgapBundleId = await resolveAirgapBundleId(clients.graphql); const range = resolveAnalyticsDateRange({ start, end, days }); - const data = await clients.graphql.makeRequest<{ - analyticsData: { series: ConsentManagerMetric[] }; - }>(CONSENT_MANAGER_ANALYTICS_DATA, { + const data = await clients.graphql.makeRequest(ConsentManagerAnalyticsDataDoc, { input: { dataSource: data_source, startDate: range.startIso, diff --git a/packages/mcp/mcp-server-consent/src/tools/consent_get_inventory_stats.ts b/packages/mcp/mcp-server-consent/src/tools/consent_get_inventory_stats.ts index abfad90c2..810b5742d 100644 --- a/packages/mcp/mcp-server-consent/src/tools/consent_get_inventory_stats.ts +++ b/packages/mcp/mcp-server-consent/src/tools/consent_get_inventory_stats.ts @@ -1,11 +1,6 @@ import { createToolResult, defineTool, z, type ToolClients } from '@transcend-io/mcp-server-base'; -import { - COOKIE_STATS, - DATA_FLOW_STATS, - type TranscendCliCookieStatsResponse, - type TranscendCliDataFlowStatsResponse, -} from '@transcend-io/sdk'; +import { CookieStatsDoc, DataFlowStatsDoc } from '../graphql.js'; import { resolveAirgapBundleId } from '../resolveAirgapBundleId.js'; export const GetInventoryStatsSchema = z.object({}); @@ -26,8 +21,8 @@ export function createConsentGetInventoryStatsTool(clients: ToolClients) { const airgapBundleId = await resolveAirgapBundleId(clients.graphql); const variables = { input: { airgapBundleId } }; const [cookieData, dfData] = await Promise.all([ - clients.graphql.makeRequest(COOKIE_STATS, variables), - clients.graphql.makeRequest(DATA_FLOW_STATS, variables), + clients.graphql.makeRequest(CookieStatsDoc, variables), + clients.graphql.makeRequest(DataFlowStatsDoc, variables), ]); return createToolResult(true, { cookies: cookieData.cookieStats, diff --git a/packages/mcp/mcp-server-consent/src/tools/consent_get_timeseries_analytics.ts b/packages/mcp/mcp-server-consent/src/tools/consent_get_timeseries_analytics.ts index 2fce705f8..734a2f4fe 100644 --- a/packages/mcp/mcp-server-consent/src/tools/consent_get_timeseries_analytics.ts +++ b/packages/mcp/mcp-server-consent/src/tools/consent_get_timeseries_analytics.ts @@ -1,14 +1,8 @@ import { createToolResult, defineTool, z, type ToolClients } from '@transcend-io/mcp-server-base'; -import { - AirgapBundleAnalyticsBinInterval, - AirgapBundleAnalyticsMetric, -} from '@transcend-io/privacy-types'; -import { - AIRGAP_BUNDLE_TIMESERIES_ANALYTICS, - type TranscendCliAirgapBundleTimeseriesAnalyticsResponse, -} from '@transcend-io/sdk'; +import { AirgapBundleAnalyticsBinInterval } from '@transcend-io/privacy-types'; import { resolveAnalyticsDateRange } from '../analyticsDateRange.js'; +import { TimeseriesAnalyticsDoc } from '../graphql.js'; import { airgapBundleAnalyticsMetricSchema } from '../normalizeAnalyticsMetric.js'; import { resolveAirgapBundleId } from '../resolveAirgapBundleId.js'; @@ -50,19 +44,15 @@ export function createConsentGetTimeseriesAnalyticsTool(clients: ToolClients) { handler: async ({ metric, start, end, days, bin_interval }) => { const airgapBundleId = await resolveAirgapBundleId(clients.graphql); const range = resolveAnalyticsDateRange({ start, end, days }); - const data = - await clients.graphql.makeRequest( - AIRGAP_BUNDLE_TIMESERIES_ANALYTICS, - { - id: airgapBundleId, - input: { - metric, - start: range.startEpoch, - end: range.endEpoch, - binInterval: bin_interval, - }, - }, - ); + const data = await clients.graphql.makeRequest(TimeseriesAnalyticsDoc, { + id: airgapBundleId, + input: { + metric, + start: range.startEpoch, + end: range.endEpoch, + binInterval: bin_interval, + }, + }); const items = data.airgapBundleTimeseriesAnalytics.items; return createToolResult(true, { diff --git a/packages/mcp/mcp-server-consent/src/tools/consent_list_airgap_bundles.ts b/packages/mcp/mcp-server-consent/src/tools/consent_list_airgap_bundles.ts index 1b82634bb..0383762e6 100644 --- a/packages/mcp/mcp-server-consent/src/tools/consent_list_airgap_bundles.ts +++ b/packages/mcp/mcp-server-consent/src/tools/consent_list_airgap_bundles.ts @@ -4,10 +4,8 @@ import { EmptySchema, type ToolClients, } from '@transcend-io/mcp-server-base'; -import { - FETCH_CONSENT_MANAGER, - type TranscendCliFetchConsentManagerResponse, -} from '@transcend-io/sdk'; + +import { FetchConsentManagerDoc } from '../graphql.js'; export const ListAirgapBundlesSchema = EmptySchema; export type ListAirgapBundlesInput = Record; @@ -23,10 +21,7 @@ export function createConsentListAirgapBundlesTool(clients: ToolClients) { annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }, zodSchema: ListAirgapBundlesSchema, handler: async (_args) => { - const data = await clients.graphql.makeRequest( - FETCH_CONSENT_MANAGER, - {}, - ); + const data = await clients.graphql.makeRequest(FetchConsentManagerDoc); return createToolResult(true, data.consentManager.consentManager); }, }); diff --git a/packages/mcp/mcp-server-consent/src/tools/consent_list_cookies.ts b/packages/mcp/mcp-server-consent/src/tools/consent_list_cookies.ts index 50a5a7cad..d02cb155e 100644 --- a/packages/mcp/mcp-server-consent/src/tools/consent_list_cookies.ts +++ b/packages/mcp/mcp-server-consent/src/tools/consent_list_cookies.ts @@ -4,8 +4,8 @@ import { CookieOrderField, OrderDirection, } from '@transcend-io/privacy-types'; -import { COOKIES, type TranscendCliCookiesResponse } from '@transcend-io/sdk'; +import { CookiesDoc } from '../graphql.js'; import { resolveAirgapBundleId } from '../resolveAirgapBundleId.js'; export const ListCookiesSchema = z.object({ @@ -26,10 +26,25 @@ export const ListCookiesSchema = z.object({ .nativeEnum(ConsentTrackerStatus) .describe('Filter by status: NEEDS_REVIEW (triage) or LIVE (approved)'), isJunk: z.boolean().optional().describe('Filter by junk status'), - showZeroActivity: z.boolean().optional().describe('Include items with zero activity'), + showZeroActivity: z + .boolean() + .optional() + .describe( + 'Include items with zero activity. Omit (default) so the NEEDS_REVIEW total matches ' + + 'consent_get_inventory_stats needReviewCount; set true for the full triage backlog ' + + 'including never-active cookies.', + ), text: z.string().optional().describe('Search text filter'), service: z.string().optional().describe('Filter by service name'), - orderField: z.nativeEnum(CookieOrderField).optional().describe('Field to sort by'), + minOccurrences: z + .number() + .min(0) + .optional() + .describe('Only return cookies with at least this many occurrences (traffic)'), + orderField: z + .nativeEnum(CookieOrderField) + .optional() + .describe('Field to sort by (e.g. occurrences to rank by traffic)'), orderDirection: z.nativeEnum(OrderDirection).optional().describe('Sort direction: ASC or DESC'), }); export type ListCookiesInput = z.infer; @@ -40,7 +55,9 @@ export function createConsentListCookiesTool(clients: ToolClients) { description: 'List cookies in your consent manager. ' + 'Requires a status filter: NEEDS_REVIEW for triage backlog, LIVE for approved cookies. ' + - 'Returns name, service, tracking purposes, activity (occurrences), junk status, and more.', + 'Returns name, service, tracking purposes, activity (occurrences), junk status, and more. ' + + 'Sort by occurrences (orderField=occurrences, orderDirection=DESC) to surface ' + + 'top-traffic cookies, and use minOccurrences to filter low-traffic noise.', category: 'Consent Management', readOnly: true, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }, @@ -53,11 +70,12 @@ export function createConsentListCookiesTool(clients: ToolClients) { showZeroActivity, text, service, + minOccurrences, orderField, orderDirection, }) => { const airgapBundleId = await resolveAirgapBundleId(clients.graphql); - const data = await clients.graphql.makeRequest(COOKIES, { + const data = await clients.graphql.makeRequest(CookiesDoc, { input: { airgapBundleId }, first: limit, offset, @@ -67,6 +85,7 @@ export function createConsentListCookiesTool(clients: ToolClients) { ...(showZeroActivity !== undefined ? { showZeroActivity } : {}), ...(text ? { text } : {}), ...(service ? { service } : {}), + ...(minOccurrences !== undefined ? { minOccurrences } : {}), }, ...(orderField && orderDirection ? { orderBy: [{ field: orderField, direction: orderDirection }] } diff --git a/packages/mcp/mcp-server-consent/src/tools/consent_list_data_flows.ts b/packages/mcp/mcp-server-consent/src/tools/consent_list_data_flows.ts index 63c5ca23b..37bdc10a3 100644 --- a/packages/mcp/mcp-server-consent/src/tools/consent_list_data_flows.ts +++ b/packages/mcp/mcp-server-consent/src/tools/consent_list_data_flows.ts @@ -2,10 +2,11 @@ import { createListResult, defineTool, z, type ToolClients } from '@transcend-io import { ConsentTrackerStatus, DataFlowOrderField, + DataFlowScope, OrderDirection, } from '@transcend-io/privacy-types'; -import { DATA_FLOWS, type TranscendCliDataFlowsResponse } from '@transcend-io/sdk'; +import { DataFlowsDoc } from '../graphql.js'; import { resolveAirgapBundleId } from '../resolveAirgapBundleId.js'; export const ListDataFlowsSchema = z.object({ @@ -26,9 +27,32 @@ export const ListDataFlowsSchema = z.object({ .nativeEnum(ConsentTrackerStatus) .describe('Filter by status: NEEDS_REVIEW (triage) or LIVE (approved)'), isJunk: z.boolean().optional().describe('Filter by junk status'), - showZeroActivity: z.boolean().optional().describe('Include items with zero activity'), + showZeroActivity: z + .boolean() + .optional() + .describe( + 'Include items with zero activity. Omit (default) so the NEEDS_REVIEW total matches ' + + 'consent_get_inventory_stats needReviewCount; set true for the full triage backlog ' + + 'including never-active flows.', + ), text: z.string().optional().describe('Search text filter'), service: z.string().optional().describe('Filter by service name'), + unmappedOnly: z + .boolean() + .optional() + .describe( + 'Return only unmapped/orphaned flows with no associated service (catalog integration). ' + + 'Useful with status=LIVE to find approved flows that are not mapped to a service.', + ), + type: z + .nativeEnum(DataFlowScope) + .optional() + .describe('Filter by data flow scope type (e.g. HOST, PATH, REGEX, CSP)'), + minOccurrences: z + .number() + .min(0) + .optional() + .describe('Only return flows with at least this many occurrences (traffic)'), orderField: z.nativeEnum(DataFlowOrderField).optional().describe('Field to sort by'), orderDirection: z.nativeEnum(OrderDirection).optional().describe('Sort direction: ASC or DESC'), }); @@ -40,7 +64,9 @@ export function createConsentListDataFlowsTool(clients: ToolClients) { description: 'List data flows (network requests) in your consent manager. ' + 'Requires a status filter: NEEDS_REVIEW for triage backlog, LIVE for approved flows. ' + - 'Returns value (URL/host), service, tracking purposes, activity (occurrences), and more.', + 'Returns value (URL/host), service, tracking purposes, activity (occurrences), and more. ' + + 'Use unmappedOnly to find approved flows with no service, type to filter by scope ' + + '(e.g. CSP), and minOccurrences to focus on high-traffic flows.', category: 'Consent Management', readOnly: true, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }, @@ -53,11 +79,14 @@ export function createConsentListDataFlowsTool(clients: ToolClients) { showZeroActivity, text, service, + unmappedOnly, + type, + minOccurrences, orderField, orderDirection, }) => { const airgapBundleId = await resolveAirgapBundleId(clients.graphql); - const data = await clients.graphql.makeRequest(DATA_FLOWS, { + const data = await clients.graphql.makeRequest(DataFlowsDoc, { input: { airgapBundleId }, first: limit, offset, @@ -66,7 +95,11 @@ export function createConsentListDataFlowsTool(clients: ToolClients) { ...(isJunk !== undefined ? { isJunk } : {}), ...(showZeroActivity !== undefined ? { showZeroActivity } : {}), ...(text ? { text } : {}), - ...(service ? { service } : {}), + // An empty-string service maps to `catalogIntegrationName IS NULL` + // server-side, so unmappedOnly takes precedence over a named service filter. + ...(unmappedOnly ? { service: '' } : service ? { service } : {}), + ...(type ? { type } : {}), + ...(minOccurrences !== undefined ? { minOccurrences } : {}), }, ...(orderField && orderDirection ? { orderBy: [{ field: orderField, direction: orderDirection }] } diff --git a/packages/mcp/mcp-server-consent/src/tools/consent_list_purposes.ts b/packages/mcp/mcp-server-consent/src/tools/consent_list_purposes.ts index 0cfb9f15f..8a667f471 100644 --- a/packages/mcp/mcp-server-consent/src/tools/consent_list_purposes.ts +++ b/packages/mcp/mcp-server-consent/src/tools/consent_list_purposes.ts @@ -1,5 +1,6 @@ import { createListResult, defineTool, z, type ToolClients } from '@transcend-io/mcp-server-base'; -import { PURPOSES, type TranscendCliPurposesResponse } from '@transcend-io/sdk'; + +import { PurposesDoc } from '../graphql.js'; export const ListPurposesSchema = z.object({ limit: z.coerce @@ -21,7 +22,7 @@ export function createConsentListPurposesTool(clients: ToolClients) { annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }, zodSchema: ListPurposesSchema, handler: async ({ limit }) => { - const data = await clients.graphql.makeRequest(PURPOSES, { + const data = await clients.graphql.makeRequest(PurposesDoc, { first: Math.min(limit, 100), }); const { nodes, totalCount } = data.purposes; diff --git a/packages/mcp/mcp-server-consent/src/tools/consent_list_regimes.ts b/packages/mcp/mcp-server-consent/src/tools/consent_list_regimes.ts index f510ac28d..ece6fda1d 100644 --- a/packages/mcp/mcp-server-consent/src/tools/consent_list_regimes.ts +++ b/packages/mcp/mcp-server-consent/src/tools/consent_list_regimes.ts @@ -1,5 +1,6 @@ import { createListResult, defineTool, z, type ToolClients } from '@transcend-io/mcp-server-base'; -import { EXPERIENCES, type TranscendCliExperiencesResponse } from '@transcend-io/sdk'; + +import { ExperiencesDoc } from '../graphql.js'; export const ListRegimesSchema = z.object({ limit: z.coerce @@ -29,7 +30,7 @@ export function createConsentListRegimesTool(clients: ToolClients) { annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }, zodSchema: ListRegimesSchema, handler: async ({ limit, offset }) => { - const data = await clients.graphql.makeRequest(EXPERIENCES, { + const data = await clients.graphql.makeRequest(ExperiencesDoc, { first: limit, offset, }); diff --git a/packages/mcp/mcp-server-consent/src/tools/consent_update_cookies.ts b/packages/mcp/mcp-server-consent/src/tools/consent_update_cookies.ts index 2517e7624..32ebb8deb 100644 --- a/packages/mcp/mcp-server-consent/src/tools/consent_update_cookies.ts +++ b/packages/mcp/mcp-server-consent/src/tools/consent_update_cookies.ts @@ -1,11 +1,7 @@ import { createToolResult, defineTool, z, type ToolClients } from '@transcend-io/mcp-server-base'; import { ConsentTrackerStatus } from '@transcend-io/privacy-types'; -import { - UPDATE_OR_CREATE_COOKIES, - type TranscendUpdateCookieInputGql, - type TranscendCliUpdateOrCreateCookiesResponse, -} from '@transcend-io/sdk'; +import { UpdateOrCreateCookiesDoc, type UpdateOrCreateCookieInput } from '../graphql.js'; import { resolveAirgapBundleId } from '../resolveAirgapBundleId.js'; export const UpdateCookieItemSchema = z.object({ @@ -43,7 +39,7 @@ export function createConsentUpdateCookiesTool(clients: ToolClients) { zodSchema: UpdateCookiesSchema, handler: async ({ cookies }) => { const airgapBundleId = await resolveAirgapBundleId(clients.graphql); - const cookieInputs: TranscendUpdateCookieInputGql[] = cookies.map((c) => ({ + const cookieInputs: UpdateOrCreateCookieInput[] = cookies.map((c) => ({ name: c.name, ...(c.trackingPurposes ? { trackingPurposes: c.trackingPurposes } : {}), ...(c.description !== undefined ? { description: c.description } : {}), @@ -51,13 +47,10 @@ export function createConsentUpdateCookiesTool(clients: ToolClients) { ...(c.isJunk !== undefined ? { isJunk: c.isJunk } : {}), ...(c.status !== undefined ? { status: c.status } : {}), })); - await clients.graphql.makeRequest( - UPDATE_OR_CREATE_COOKIES, - { - airgapBundleId, - cookies: cookieInputs, - }, - ); + await clients.graphql.makeRequest(UpdateOrCreateCookiesDoc, { + airgapBundleId, + cookies: cookieInputs, + }); return createToolResult(true, { updated: cookieInputs.length, cookies: cookieInputs.map((c) => ({ diff --git a/packages/mcp/mcp-server-consent/src/tools/consent_update_data_flows.ts b/packages/mcp/mcp-server-consent/src/tools/consent_update_data_flows.ts index 30c9d4140..b676373e4 100644 --- a/packages/mcp/mcp-server-consent/src/tools/consent_update_data_flows.ts +++ b/packages/mcp/mcp-server-consent/src/tools/consent_update_data_flows.ts @@ -1,11 +1,7 @@ import { createToolResult, defineTool, z, type ToolClients } from '@transcend-io/mcp-server-base'; import { ConsentTrackerStatus } from '@transcend-io/privacy-types'; -import { - UPDATE_DATA_FLOWS, - type TranscendUpdateDataFlowInputGql, - type TranscendCliUpdateDataFlowsResponse, -} from '@transcend-io/sdk'; +import { UpdateDataFlowsDoc, type UpdateDataFlowInput } from '../graphql.js'; import { resolveAirgapBundleId } from '../resolveAirgapBundleId.js'; export const UpdateDataFlowItemSchema = z.object({ @@ -39,7 +35,7 @@ export function createConsentUpdateDataFlowsTool(clients: ToolClients) { zodSchema: UpdateDataFlowsSchema, handler: async ({ dataFlows }) => { const airgapBundleId = await resolveAirgapBundleId(clients.graphql); - const dfInputs: TranscendUpdateDataFlowInputGql[] = dataFlows.map((df) => ({ + const dfInputs: UpdateDataFlowInput[] = dataFlows.map((df) => ({ id: df.id, ...(df.trackingPurposes ? { purposeIds: df.trackingPurposes } : {}), ...(df.description !== undefined ? { description: df.description } : {}), @@ -47,13 +43,10 @@ export function createConsentUpdateDataFlowsTool(clients: ToolClients) { ...(df.isJunk !== undefined ? { isJunk: df.isJunk } : {}), ...(df.status !== undefined ? { status: df.status } : {}), })); - const data = await clients.graphql.makeRequest( - UPDATE_DATA_FLOWS, - { - airgapBundleId, - dataFlows: dfInputs, - }, - ); + const data = await clients.graphql.makeRequest(UpdateDataFlowsDoc, { + airgapBundleId, + dataFlows: dfInputs, + }); return createToolResult(true, { updated: data.updateDataFlows.dataFlows.length, dataFlows: data.updateDataFlows.dataFlows.map((df) => ({ diff --git a/packages/mcp/mcp-server-consent/tests/consent.test.ts b/packages/mcp/mcp-server-consent/tests/consent.test.ts index a35823a84..da50b77a5 100644 --- a/packages/mcp/mcp-server-consent/tests/consent.test.ts +++ b/packages/mcp/mcp-server-consent/tests/consent.test.ts @@ -93,6 +93,74 @@ describe('Consent Tools', () => { }); }); + describe('consent_list_data_flows', () => { + const mockBundle = () => + mockGraphql.makeRequest.mockResolvedValueOnce({ + consentManager: { consentManager: { id: 'bundle-1' } }, + }); + + const runDataFlows = async (input: Record) => { + mockBundle(); + mockGraphql.makeRequest.mockResolvedValueOnce({ + dataFlows: { nodes: [], totalCount: 0 }, + }); + const tool = getTools().find((t) => t.name === 'consent_list_data_flows')!; + await tool.handler(tool.zodSchema.parse(input)); + // Second call is the DATA_FLOWS query; grab its variables. + return mockGraphql.makeRequest.mock.calls[1][1]; + }; + + it('sends filterBy.service = "" when unmappedOnly is set (takes precedence over service)', async () => { + const variables = await runDataFlows({ + status: 'LIVE', + unmappedOnly: true, + service: 'Google Analytics', + }); + expect(variables.filterBy.service).toBe(''); + }); + + it('passes through type and minOccurrences filters', async () => { + const variables = await runDataFlows({ + status: 'NEEDS_REVIEW', + type: 'CSP', + minOccurrences: 10, + }); + expect(variables.filterBy).toMatchObject({ type: 'CSP', minOccurrences: 10 }); + }); + + it('omits showZeroActivity by default for NEEDS_REVIEW so counts match inventory stats', async () => { + const variables = await runDataFlows({ status: 'NEEDS_REVIEW' }); + expect(variables.filterBy).not.toHaveProperty('showZeroActivity'); + }); + }); + + describe('consent_list_cookies', () => { + it('forwards orderBy when sorting by occurrences', async () => { + mockGraphql.makeRequest.mockResolvedValueOnce({ + consentManager: { consentManager: { id: 'bundle-1' } }, + }); + mockGraphql.makeRequest.mockResolvedValueOnce({ + cookies: { nodes: [], totalCount: 0 }, + }); + const tool = getTools().find((t) => t.name === 'consent_list_cookies')!; + await tool.handler( + tool.zodSchema.parse({ + status: 'LIVE', + orderField: 'occurrences', + orderDirection: 'DESC', + }), + ); + const variables = mockGraphql.makeRequest.mock.calls[1][1]; + expect(variables.orderBy).toEqual([{ field: 'occurrences', direction: 'DESC' }]); + }); + + it('zodSchema accepts occurrences as an orderField', () => { + const tool = getTools().find((t) => t.name === 'consent_list_cookies')!; + const result = tool.zodSchema.safeParse({ status: 'LIVE', orderField: 'occurrences' }); + expect(result.success).toBe(true); + }); + }); + describe('consent_get_aggregate_analytics', () => { it('queries aggregate analytics with resolved bundle id', async () => { mockGraphql.makeRequest diff --git a/packages/privacy-types/src/consentManager.ts b/packages/privacy-types/src/consentManager.ts index cc044b1df..a59d25bb8 100644 --- a/packages/privacy-types/src/consentManager.ts +++ b/packages/privacy-types/src/consentManager.ts @@ -221,6 +221,8 @@ export const CookieOrderField = makeEnum({ CreatedAt: 'createdAt', /** The time the cookie was updated */ UpdatedAt: 'updatedAt', + /** The number of occurrences (traffic) of this cookie */ + Occurrences: 'occurrences', }); /** Type override */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9e70c2c7c..4c6da3b68 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -641,6 +641,9 @@ importers: packages/mcp/mcp-server-consent: dependencies: + '@graphql-typed-document-node/core': + specifier: 'catalog:' + version: 3.2.0(graphql@16.14.2) '@modelcontextprotocol/sdk': specifier: 'catalog:' version: 1.29.0(zod@4.4.3) @@ -650,9 +653,9 @@ importers: '@transcend-io/privacy-types': specifier: workspace:* version: link:../../privacy-types - '@transcend-io/sdk': - specifier: workspace:* - version: link:../../sdk + graphql: + specifier: 'catalog:' + version: 16.14.2 zod: specifier: 'catalog:' version: 4.4.3 diff --git a/schema.graphql b/schema.graphql index 7b815d765..de6dfa466 100644 --- a/schema.graphql +++ b/schema.graphql @@ -2248,6 +2248,7 @@ enum CookieOrderField { createdAt updatedAt lastDiscoveredAt + occurrences } enum DataFlowType { @@ -5284,7 +5285,7 @@ type Mutation { createContract(input: CreateContractInput!): CreateContractPayload! createContractScan(input: CreateContractScanInput!): CreateContractScanPayload! createCopiedComplianceReport(useMaster: Boolean, isExportCsv: Boolean, input: CreateCopiedComplianceReportInput!): CreateCopiedComplianceReportPayload! - createCustomFunction(input: CreateCustomFunctionInput!, dhEncrypted: String!): CreateCustomFunctionPayload! + createCustomFunction(input: CreateCustomFunctionInput!, dhEncrypted: String): CreateCustomFunctionPayload! createDataCollection(input: NewDataCollectionInput!): CreateDataCollectionPayload! createDataFlows(input: CreateDataFlowInput!): CreateDataFlowsPayload! createDataReport(input: CreateDataReportInput!): CreateDataReportPayload! @@ -5605,7 +5606,7 @@ type Mutation { updateConsentUiVariant(input: UpdateConsentUiVariantInput!): UpdateConsentUiVariantPayload! updateContracts(input: UpdateContractsInput!): UpdateContractsPayload! updateContractScans(input: UpdateContractScansInput!): UpdateContractScansPayload! - updateCustomFunction(input: UpdateCustomFunctionInput!, dhEncrypted: String!): UpdateCustomFunctionPayload! + updateCustomFunction(input: UpdateCustomFunctionInput!, dhEncrypted: String): UpdateCustomFunctionPayload! updateDataFlows(input: UpdateDataFlowsInput!): UpdateDataFlowsPayload! updateDataPointLevel(input: UpdateDataPointLevelInput!): UpdateDataPointLevelPayload! updateDataReports(input: UpdateDataReportsInput!): UpdateDataReportsPayload! @@ -10410,6 +10411,8 @@ input CreateCustomFunctionInput { description: String ownerUserIds: [ID!] ownerTeamIds: [ID!] + signedCodeJwt: String + signedCodeContextJwt: String } type CreatedApiKey implements ApiKeyInterface { @@ -11257,6 +11260,7 @@ type CustomFunction { signedCodeContextJwt: String! lifecycleState: CustomFunctionLifecycleState! type: CustomFunctionType! + sombraId: ID runsLink: String! owners: [UserPreview!]! activeVersion: CustomFunctionVersionPreview @@ -17250,7 +17254,6 @@ input ReleaseAirgapPrefixModuleVersionInput { type ReleaseAirgapPrefixModuleVersionResult { airgapPrefixModuleId: ID! airgapPrefixModuleReleaseId: ID! - created: Boolean! } type RemoveDataSiloFromWorkflowConfigPayload { @@ -20645,6 +20648,7 @@ type UnwrapCustomFunctionPayload { input UnwrapCustomFunctionInput { id: ID! + versionId: ID } type UnwrapEmailContentsPayload { @@ -21423,6 +21427,8 @@ input UpdateCustomFunctionInput { name: String description: String ownerUserIds: [ID!] + signedCodeJwt: String + signedCodeContextJwt: String } input UpdateCustomFunctionUserDefinedEnvInput { @@ -22701,6 +22707,8 @@ input UpdateStandaloneCustomFunctionInput { description: String ownerUserIds: [ID!] ownerTeamIds: [ID!] + signedCodeJwt: String + signedCodeContextJwt: String } input UpdateSubDataPointInput { @@ -23226,6 +23234,7 @@ type WorkflowConfig { expiryTime: [ExpiryTime!] WorkflowConfigAttributeKeys: [WorkflowConfigAttributeKey!] internalName: String + unresolvedRequiredIdentifierCount: Int } type WorkflowConfigAttributeKey { @@ -23358,6 +23367,7 @@ type WorkflowIdentifier implements WorkflowIdentifierBaseInterface { destinationDataSilos: [IdentifierDestinationDataSilo!]! missingIdBehavior: MissingIdBehavior! isDisabled: Boolean! + hasResolutionPath: Boolean! } type WorkflowIdentifierBase implements WorkflowIdentifierBaseInterface {