@reproapp/mcp 0.0.8 → 0.0.10

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.
Files changed (3) hide show
  1. package/README.md +27 -170
  2. package/package.json +1 -1
  3. package/src/index.js +323 -38
package/README.md CHANGED
@@ -1,12 +1,12 @@
1
- # @reproapp/mcp
1
+ # Repro MCP Server
2
2
 
3
- Repro MCP server for AI-assisted debugging and privacy policy workflows.
3
+ Add Repro tools to an AI agent that supports MCP. Once connected, the agent can
4
+ inspect Repro sessions, requests, traces, database changes, and privacy policies
5
+ without you copying data out of Repro manually.
4
6
 
5
- `@reproapp/mcp` runs over stdio for local AI clients and forwards MCP tool calls to Repro services. It can also run as an HTTP MCP server in a container when `PORT` is set or `REPRO_MCP_TRANSPORT=http`.
7
+ ## Add To Your AI Agent
6
8
 
7
- ## AI Client Setup
8
-
9
- Default hosted Repro setup:
9
+ Use the default hosted Repro setup:
10
10
 
11
11
  ```json
12
12
  {
@@ -19,181 +19,38 @@ Default hosted Repro setup:
19
19
  }
20
20
  ```
21
21
 
22
- Custom Repro deployment:
23
-
24
- ```json
25
- {
26
- "mcpServers": {
27
- "repro": {
28
- "command": "npx",
29
- "args": ["-y", "@reproapp/mcp@latest"],
30
- "env": {
31
- "REPRO_AI_BASE_URL": "https://your-repro-ai-url/",
32
- "REPRO_API_BASE_URL": "https://your-repro-api-url/api/",
33
- "REPRO_WEB_BASE_URL": "https://your-repro-web-url/"
34
- }
35
- }
36
- }
37
- }
38
- ```
39
-
40
22
  The installed binary is `repro-mcp`.
41
23
 
42
- ## Agent Rules
43
-
44
- When a user asks about Repro sessions, requests, traces, privacy policies, or app configuration, the AI client must use the Repro MCP tools only. It must not write local scripts, call Repro HTTP APIs directly, read cached Repro auth files, or patch policy JSON outside the MCP tool surface.
45
-
46
- Privacy policy updates are intentionally handle-based:
47
-
48
- - Use `inspect_privacy_policy` or `list_privacy_policy_blockers` first.
49
- - For one policy statement, pass the exact `statementSid` to `update_privacy_policy`.
50
- - For all currently listed blockers, pass the returned `blockerSetId` to `update_privacy_policy`.
51
- - Broad natural-language updates such as "make all blockers keep exact" are read-only/dry-run unless they include a `blockerSetId` or exact statement ids.
52
- - `KEEP_EXACT` on sensitive or raw-text review statements requires `force=true` and a human reason.
53
-
54
- ## Configuration
55
-
56
- Built-in defaults:
57
-
58
- - `REPRO_AI_BASE_URL=https://repro-ai-u8lhw.ondigitalocean.app`
59
- - `REPRO_API_BASE_URL=https://repro-api-d7288.ondigitalocean.app/api`
60
- - `REPRO_WEB_BASE_URL=https://getrepro.io`
61
-
62
- Optional:
63
-
64
- - `REPRO_MCP_TRANSPORT` (`stdio` or `http`; default `http` when `PORT` is set, otherwise `stdio`)
65
- - `REPRO_MCP_HTTP_HOST`
66
- - `REPRO_MCP_HTTP_PORT`
67
- - `REPRO_MCP_HTTP_PATH`
68
- - `REPRO_MCP_CORS_ORIGIN`
69
- - `REPRO_API_BASE_URL`
70
- - `REPRO_API_PUBLIC_BASE_URL`
71
- - `REPRO_WEB_BASE_URL`
72
- - `REPRO_AI_TOKEN`
73
- - `REPRO_API_TOKEN`
74
- - `REPRO_DEFAULT_TENANT_ID`
75
- - `REPRO_APP_ID`
76
- - `REPRO_REQUEST_TIMEOUT_MS`
77
- - `REPRO_INVESTIGATE_TIMEOUT_MS`
78
- - `REPRO_MCP_ENV_FILE`
79
- - `REPRO_MCP_AUTH_FILE`
80
- - `REPRO_MCP_AUTH_TIMEOUT_MS`
81
- - `REPRO_MCP_AUTO_AUTH`
82
- - `REPRO_MCP_AUTH_MODE` (`wait` or `prompt`; default `prompt` for HTTP and `wait` for stdio)
83
- - `REPRO_MCP_OPEN_BROWSER`
84
- - `REPRO_MCP_DEBUG_LOG`
85
- - `REPRO_MCP_LAUNCH_LOG`
86
- - `REPRO_PATH_MAPPINGS`
87
-
88
- Hosted HTTP example:
89
-
90
- ```bash
91
- export REPRO_MCP_HTTP_HOST="0.0.0.0"
92
- export REPRO_MCP_HTTP_PORT="8080"
93
- export REPRO_MCP_HTTP_PATH="/mcp"
94
- export REPRO_AI_BASE_URL="http://repro-ai:4011/"
95
- export REPRO_API_BASE_URL="http://api:4010/api/"
96
- export REPRO_API_PUBLIC_BASE_URL="https://api.example.com/api/"
97
- export REPRO_WEB_BASE_URL="https://app.example.com/"
98
- export REPRO_DEFAULT_TENANT_ID="TENANT_123"
99
- export REPRO_INVESTIGATE_TIMEOUT_MS="90000"
100
- export REPRO_MCP_AUTO_AUTH="true"
101
- export REPRO_MCP_AUTH_MODE="prompt"
102
- export REPRO_MCP_OPEN_BROWSER="false"
103
- export REPRO_PATH_MAPPINGS='[
104
- {
105
- "remotePrefix": "/srv/repro-demo",
106
- "localPrefix": "/Users/alice/work/repro-demo"
107
- }
108
- ]'
109
- ```
110
-
111
- `run-repro-mcp.sh` loads `repro-mcp/.env`, then `repro-mcp/.env.local`, then the file pointed to by `REPRO_MCP_ENV_FILE` when provided. Environment variables already set by the process take precedence in `src/index.js`; for DigitalOcean App Platform, prefer setting values in the app's environment configuration instead of committing an env file.
112
-
113
- `REPRO_API_BASE_URL` is used for privacy policy inspection/update tools and browser authentication. `REPRO_WEB_BASE_URL` is used for Privacy Studio links.
114
-
115
- For hosted MCP, point `REPRO_API_BASE_URL` at the private/internal API address used by the MCP container. Set `REPRO_API_PUBLIC_BASE_URL` to the public API origin users can open in a browser. The MCP server sends that public origin to the API when it creates browser-auth sessions, so the returned authorize URL is browser-accessible even when MCP talks to the API over VPC.
116
-
117
- `REPRO_PATH_MAPPINGS` is optional. Use it only when telemetry contains deployed code paths that should map to a checkout available inside the MCP runtime. For a hosted container without a mounted checkout, keep it as `[]`.
118
-
119
- For local development, copy `.env.local.example` to `.env.local`. It contains the old localhost defaults:
120
-
121
- - `REPRO_AI_BASE_URL=http://127.0.0.1:4011`
122
- - `REPRO_API_BASE_URL=http://127.0.0.1:4010/api`
123
- - `REPRO_WEB_BASE_URL=http://127.0.0.1:3000`
124
- - local `REPRO_PATH_MAPPINGS` for the demo checkout
125
-
126
- If `REPRO_AI_TOKEN` and `REPRO_API_TOKEN` are not set, `repro-mcp` starts a browser login flow against `REPRO_API_BASE_URL`, caches the resulting app-user bearer token, and reuses it on future requests. Stdio uses `REPRO_MCP_AUTH_MODE=wait`: it prints the authorize URL and waits for approval. HTTP uses `REPRO_MCP_AUTH_MODE=prompt`: the first protected MCP request returns a JSON-RPC error with `error.data.authorizeUrl`; open that URL, approve access, then retry the same MCP request.
127
-
128
- For the cached app-user token to work with `repro-ai`, configure `repro-ai` with the same `APP_USER_JWT_SECRET` as `repro-api`. Static `REPRO_AI_TOKEN` / `REPRO_API_TOKEN` values are optional overrides, not the normal hosted browser-auth path.
24
+ ## Use It
129
25
 
130
- `investigate` can legitimately take longer than the lower-level telemetry tools because it may retrieve scope context and run planner/answerer model calls. `REPRO_REQUEST_TIMEOUT_MS` controls the base timeout for all backend calls; `REPRO_INVESTIGATE_TIMEOUT_MS` overrides it just for `investigate` and defaults to at least 90 seconds.
26
+ After adding the MCP server, ask your AI agent Repro-specific questions:
131
27
 
132
- ## DigitalOcean
28
+ ```text
29
+ Investigate why session SESSION_123 failed checkout.
133
30
 
134
- Container build:
31
+ Show the request, trace, and DB evidence for this bug report.
135
32
 
136
- ```bash
137
- docker build -t repro-mcp ./repro-mcp
138
- ```
33
+ Inspect privacy policy blockers for APP_123, serviceName orders-api, in dev.
139
34
 
140
- Minimum production environment:
141
-
142
- ```bash
143
- REPRO_MCP_HTTP_HOST=0.0.0.0
144
- PORT=8080
145
- REPRO_MCP_HTTP_PATH=/mcp
146
- REPRO_AI_BASE_URL=http://repro-ai:4011/
147
- REPRO_API_BASE_URL=http://api:4010/api/
148
- REPRO_API_PUBLIC_BASE_URL=https://your-public-api-host/api/
149
- REPRO_WEB_BASE_URL=https://your-repro-web-host/
150
- REPRO_MCP_AUTO_AUTH=true
151
- REPRO_MCP_AUTH_MODE=prompt
152
- REPRO_MCP_OPEN_BROWSER=false
153
- REPRO_PATH_MAPPINGS=[]
35
+ Update these privacy blockers using blockerSetId BLOCKERS_123, then explain what still needs review.
154
36
  ```
155
37
 
156
- DigitalOcean service requirements:
157
-
158
- - `repro-mcp` runs as a Web Service from `repro-mcp/Dockerfile` on port `8080`.
159
- - `repro-mcp` reaches `repro-api` and `repro-ai` through VPC/internal routing.
160
- - `REPRO_API_PUBLIC_BASE_URL` points to the public API URL used only for the browser authorize page.
161
- - `repro-api` and `repro-ai` share the same `APP_USER_JWT_SECRET`, so the browser-auth app-user JWT can authorize both services.
162
-
163
- HTTP endpoints:
164
-
165
- - `GET /health` and `GET /healthz`: health checks
166
- - `POST /mcp`: JSON-RPC MCP requests
167
- - `GET /mcp`: event-stream placeholder for clients that open a stream
38
+ If authentication is required, the agent will provide an authorization URL.
39
+ Open it, approve access, then retry the same request.
168
40
 
169
- The same tool handler backs stdio and HTTP transports.
41
+ ## Privacy Policy Safety
170
42
 
171
- ## Run
43
+ Privacy policy updates are handle-based:
172
44
 
173
- ```bash
174
- npm start
175
- ```
176
-
177
- `npm start` runs the HTTP server. Local stdio MCP clients should use:
45
+ - Pass `serviceName` when an app has separate runtime policies per backend service.
46
+ - Inspect first with `inspect_privacy_policy` or `list_privacy_policy_blockers`.
47
+ - Update one statement with its exact `statementSid`.
48
+ - Update a listed blocker set with the exact `blockerSetId`.
49
+ - Broad requests such as "make all blockers keep exact" are read-only unless they include exact ids.
50
+ - Sensitive `KEEP_EXACT` decisions require an explicit human reason.
178
51
 
179
- ```bash
180
- npm run start:stdio
181
- ```
52
+ ## Agent Rules
182
53
 
183
- ## Tools
184
-
185
- - `investigate`
186
- - `resolve_scope`
187
- - `list_actions`
188
- - `list_requests`
189
- - `get_request`
190
- - `get_captured_value`
191
- - `get_requests_relative_to_anchor`
192
- - `get_db_changes`
193
- - `get_trace`
194
- - `get_action_path`
195
- - `get_function_candidates`
196
- - `inspect_privacy_policy`
197
- - `list_privacy_policy_blockers`
198
- - `update_privacy_policy`
199
- - `approve_privacy_policy`
54
+ For Repro-scoped questions about sessions, requests, traces, privacy policies,
55
+ apps, or configuration, use the Repro MCP tools. Do not call Repro HTTP APIs
56
+ directly or edit policy JSON outside Repro.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reproapp/mcp",
3
- "version": "0.0.8",
3
+ "version": "0.0.10",
4
4
  "description": "Repro MCP server for AI-assisted debugging and privacy policy workflows",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
package/src/index.js CHANGED
@@ -16,6 +16,8 @@ const SERVER_INSTRUCTIONS = [
16
16
  'For Repro-scoped questions about sessions, requests, traces, privacy policies, apps, or configuration, use the Repro MCP tools only.',
17
17
  'Do not create local scripts, read cached Repro auth files, or call Repro HTTP APIs directly for Repro data or policy workflows.',
18
18
  'For privacy policy writes, inspect first and then use exact statementSid(s) or blockerSetId. Broad natural-language policy update queries are read-only.',
19
+ 'For factual debugging questions, use exact evidence tools first and answer from their structured output. Use investigate only after exact evidence is gathered, or when the user explicitly asks for synthesis/root cause.',
20
+ 'Never answer that captured client data is absent because code appears to omit it. Verify with request, DB, trace, function-call, and message payload tools, then say exactly what Repro captured or did not capture.',
19
21
  ].join(' ');
20
22
  const DEFAULT_REPRO_AI_BASE_URL = 'https://repro-ai-u8lhw.ondigitalocean.app';
21
23
  const DEFAULT_REPRO_API_BASE_URL = 'https://repro-api-d7288.ondigitalocean.app/api';
@@ -36,6 +38,7 @@ const LOCAL_TOOL_DEFINITIONS = [
36
38
  properties: {
37
39
  tenantId: { type: 'string' },
38
40
  appId: { type: 'string' },
41
+ serviceName: { type: 'string' },
39
42
  sessionId: { type: 'string' },
40
43
  environment: { type: 'string' },
41
44
  state: { type: 'string', enum: ['active', 'pending', 'approved'] },
@@ -54,6 +57,7 @@ const LOCAL_TOOL_DEFINITIONS = [
54
57
  properties: {
55
58
  tenantId: { type: 'string' },
56
59
  appId: { type: 'string' },
60
+ serviceName: { type: 'string' },
57
61
  appIds: {
58
62
  type: 'array',
59
63
  items: { type: 'string' },
@@ -77,6 +81,7 @@ const LOCAL_TOOL_DEFINITIONS = [
77
81
  properties: {
78
82
  tenantId: { type: 'string' },
79
83
  appId: { type: 'string' },
84
+ serviceName: { type: 'string' },
80
85
  environment: { type: 'string' },
81
86
  state: { type: 'string', enum: ['active', 'pending', 'approved'] },
82
87
  query: { type: 'string' },
@@ -99,6 +104,7 @@ const LOCAL_TOOL_DEFINITIONS = [
99
104
  properties: {
100
105
  tenantId: { type: 'string' },
101
106
  appId: { type: 'string' },
107
+ serviceName: { type: 'string' },
102
108
  environment: { type: 'string' },
103
109
  state: { type: 'string', enum: ['active', 'pending', 'approved'] },
104
110
  query: { type: 'string' },
@@ -117,6 +123,7 @@ const LOCAL_TOOL_DEFINITIONS = [
117
123
  properties: {
118
124
  tenantId: { type: 'string' },
119
125
  appId: { type: 'string' },
126
+ serviceName: { type: 'string' },
120
127
  environment: { type: 'string' },
121
128
  state: { type: 'string', enum: ['active', 'pending', 'approved'] },
122
129
  query: { type: 'string' },
@@ -168,6 +175,7 @@ const LOCAL_TOOL_DEFINITIONS = [
168
175
  properties: {
169
176
  tenantId: { type: 'string' },
170
177
  appId: { type: 'string' },
178
+ serviceName: { type: 'string' },
171
179
  environment: { type: 'string' },
172
180
  openUi: { type: 'boolean' },
173
181
  },
@@ -193,6 +201,35 @@ const LOCAL_TOOL_DEFINITIONS = [
193
201
  },
194
202
  ];
195
203
 
204
+ const TOOL_DESCRIPTION_OVERRIDES = {
205
+ investigate:
206
+ 'Synthesize a root-cause hypothesis from Repro evidence after exact telemetry tools have been checked. Do not use this as the first or only tool for factual inventory questions about requests, DB changes, traces, function args/returns, emails, Kafka/RabbitMQ/messages, or payload contents; call the exact evidence tool first and cite that output.',
207
+ list_requests:
208
+ 'Exact evidence tool: list captured HTTP and backend transport requests in scope. Use this before investigate whenever the user asks what request happened, which endpoint ran, request timing/status, or which request RID anchors later DB/trace/message evidence.',
209
+ get_request:
210
+ 'Exact evidence tool: fetch one captured request by RID, including request/response bodies and captured value refs when present. Use this before answering factual questions about a specific request payload, response, endpoint, or transport message wrapper.',
211
+ list_session_evidence:
212
+ 'Generic exact evidence tool: list every supported kind of captured session evidence without client-specific assumptions. Use this first for factual inventory questions about emails, messages, requests, DB changes, traces, actions, payloads, or whether data exists.',
213
+ get_session_evidence:
214
+ 'Generic exact evidence tool: fetch the full structured data for one item returned by list_session_evidence. Use this before claiming captured fields are absent or omitted.',
215
+ get_requests_relative_to_anchor:
216
+ 'Exact evidence tool: list captured requests before or after an action/request/DB anchor. Use for ordering questions and localizing the request sequence around a UI action or backend event.',
217
+ get_db_changes:
218
+ 'Exact evidence tool: fetch captured DB/Redis changes. Use this before answering what collections, documents, fields, queries, mutations, or result metadata changed. Set includeQueryDetails when the user needs query/update/body details.',
219
+ get_trace:
220
+ 'Exact evidence tool: fetch captured trace batches for a request RID. Use this before answering which functions ran, trace timing, thrown errors, or trace event details for a request.',
221
+ get_action_path:
222
+ 'Exact evidence tool: fetch the correlated action path with requests, DB changes, and emails/messages when available. Use this to connect a UI action to backend evidence before giving a narrative answer.',
223
+ get_function_candidates:
224
+ 'Exact evidence tool: find likely captured functions for an action/request/function search. Use this before get_function_calls when the exact function anchor is unclear.',
225
+ get_function_calls:
226
+ 'Exact evidence tool: fetch concrete captured function calls, arguments, return values, errors, and value refs. Use this before answering what function handled something or what args/returns were captured.',
227
+ get_kafka_messages:
228
+ 'Exact evidence tool: fetch captured Kafka publish/consume payloads. Use this for messaging questions and payload contents; for exact single-message lookups, the MCP server expands captured large values automatically.',
229
+ get_captured_value:
230
+ 'Exact evidence tool: fetch the full masked value behind a captured valueId. Use this whenever another tool reports a captured value reference or large_value_available before claiming the payload is unavailable.',
231
+ };
232
+
196
233
  function loadConfig(env = process.env) {
197
234
  const baseUrl = normalizeBaseUrl(env.REPRO_AI_BASE_URL || DEFAULT_REPRO_AI_BASE_URL);
198
235
  const apiBaseUrl = normalizeOptionalBaseUrl(env.REPRO_API_BASE_URL, DEFAULT_REPRO_API_BASE_URL);
@@ -1141,11 +1178,20 @@ async function handleRpcMessage(message, config, client, getTools) {
1141
1178
  function publicToolDefinition(tool) {
1142
1179
  return {
1143
1180
  name: tool.name,
1144
- description: tool.description,
1181
+ description: publicToolDescription(tool),
1145
1182
  inputSchema: tool.inputSchema,
1146
1183
  };
1147
1184
  }
1148
1185
 
1186
+ function publicToolDescription(tool) {
1187
+ const base = typeof tool?.description === 'string' ? tool.description.trim() : '';
1188
+ const override = TOOL_DESCRIPTION_OVERRIDES[tool?.name];
1189
+ if (!override) {
1190
+ return base;
1191
+ }
1192
+ return base ? `${override} ${base}` : override;
1193
+ }
1194
+
1149
1195
  function normalizeToolCatalog(catalog) {
1150
1196
  if (!catalog || typeof catalog !== 'object' || !Array.isArray(catalog.tools)) {
1151
1197
  throw new Error('repro-ai returned an invalid tool catalog');
@@ -1226,7 +1272,7 @@ function inputWithExactCapturedPayloads(name, input) {
1226
1272
  }
1227
1273
 
1228
1274
  async function expandLinkedCapturedPayloads(name, response, client, originalInput = {}) {
1229
- if (name !== 'get_kafka_messages') {
1275
+ if (name === 'get_captured_value') {
1230
1276
  return response;
1231
1277
  }
1232
1278
 
@@ -1235,20 +1281,41 @@ async function expandLinkedCapturedPayloads(name, response, client, originalInpu
1235
1281
 
1236
1282
  while (true) {
1237
1283
  const links = collectCapturedPayloadLinks(root)
1238
- .filter((entry) => !visited.has(capturedPayloadLinkKey(entry.link)));
1239
- if (!links.length) {
1284
+ .filter((entry) => !visited.has(capturedPayloadExpansionKey(entry)));
1285
+ const valueRefs = collectCapturedValueRefs(root)
1286
+ .filter((entry) => !visited.has(capturedPayloadExpansionKey(entry)));
1287
+ const expansions = [...links, ...valueRefs];
1288
+ if (!expansions.length) {
1240
1289
  return root;
1241
1290
  }
1242
1291
 
1243
- for (const { link } of links) {
1244
- const key = capturedPayloadLinkKey(link);
1292
+ for (const expansion of expansions) {
1293
+ const key = capturedPayloadExpansionKey(expansion);
1245
1294
  visited.add(key);
1246
1295
 
1247
- const toolName = typeof link.tool === 'string' ? link.tool : '';
1248
- if (toolName !== 'get_kafka_messages') {
1249
- throw new Error(`Cannot expand captured payload through unsupported MCP tool: ${toolName || 'unknown'}`);
1296
+ if (expansion.kind === 'value-capture') {
1297
+ const valueId = expansion.capture?.valueId;
1298
+ if (typeof valueId !== 'string' || !valueId.trim()) {
1299
+ continue;
1300
+ }
1301
+ const expanded = await client.executeTool('get_captured_value', {
1302
+ ...originalInput,
1303
+ valueId: valueId.trim(),
1304
+ });
1305
+ const value = expanded?.data?.capturedValue?.value ?? expanded?.capturedValue?.value;
1306
+ if (typeof value === 'undefined') {
1307
+ throw new Error(`Repro MCP could not expand captured value ${valueId}`);
1308
+ }
1309
+ expansion.holder[expansion.targetKey] = value;
1310
+ const availabilityKey = `${expansion.targetKey}Availability`;
1311
+ if (typeof expansion.holder[availabilityKey] === 'string') {
1312
+ expansion.holder[availabilityKey] = 'captured';
1313
+ }
1314
+ continue;
1250
1315
  }
1251
1316
 
1317
+ const { link } = expansion;
1318
+ const toolName = typeof link.tool === 'string' ? link.tool : '';
1252
1319
  const linkArgs = link.arguments && typeof link.arguments === 'object' && !Array.isArray(link.arguments)
1253
1320
  ? link.arguments
1254
1321
  : {};
@@ -1299,8 +1366,72 @@ function collectCapturedPayloadLinks(value, out = []) {
1299
1366
  return out;
1300
1367
  }
1301
1368
 
1302
- function capturedPayloadLinkKey(link) {
1369
+ function collectCapturedValueRefs(value, out = []) {
1370
+ if (!value || typeof value !== 'object') {
1371
+ return out;
1372
+ }
1373
+ if (Array.isArray(value)) {
1374
+ for (const item of value) {
1375
+ collectCapturedValueRefs(item, out);
1376
+ }
1377
+ return out;
1378
+ }
1379
+
1380
+ for (const [key, nested] of Object.entries(value)) {
1381
+ if (
1382
+ key.endsWith('ValueCapture') &&
1383
+ nested &&
1384
+ typeof nested === 'object' &&
1385
+ !Array.isArray(nested) &&
1386
+ nested.status === 'stored' &&
1387
+ typeof nested.valueId === 'string' &&
1388
+ nested.valueId.trim()
1389
+ ) {
1390
+ const targetKey = valueCaptureTargetKey(key);
1391
+ if (targetKey && shouldExpandValueCaptureTarget(value[targetKey])) {
1392
+ out.push({
1393
+ kind: 'value-capture',
1394
+ holder: value,
1395
+ captureKey: key,
1396
+ targetKey,
1397
+ capture: nested,
1398
+ });
1399
+ }
1400
+ }
1401
+ collectCapturedValueRefs(nested, out);
1402
+ }
1403
+ return out;
1404
+ }
1405
+
1406
+ function shouldExpandValueCaptureTarget(value) {
1407
+ if (typeof value === 'undefined' || value === null) return true;
1408
+ return Boolean(
1409
+ value &&
1410
+ typeof value === 'object' &&
1411
+ !Array.isArray(value) &&
1412
+ value.__kind === 'omitted'
1413
+ );
1414
+ }
1415
+
1416
+ function valueCaptureTargetKey(key) {
1417
+ if (key === 'responseBodyValueCapture') return 'responseBody';
1418
+ if (key === 'respBodyValueCapture') return 'respBody';
1419
+ if (!key.endsWith('ValueCapture')) return null;
1420
+ const base = key.slice(0, -'ValueCapture'.length);
1421
+ return base || null;
1422
+ }
1423
+
1424
+ function capturedPayloadExpansionKey(expansion) {
1425
+ if (expansion?.kind === 'value-capture') {
1426
+ return JSON.stringify({
1427
+ kind: 'value-capture',
1428
+ valueId: expansion.capture?.valueId ?? null,
1429
+ targetKey: expansion.targetKey ?? null,
1430
+ });
1431
+ }
1432
+ const link = expansion?.link;
1303
1433
  return JSON.stringify({
1434
+ kind: 'mcp-link',
1304
1435
  tool: link?.tool ?? null,
1305
1436
  path: link?.path ?? null,
1306
1437
  arguments: link?.arguments ?? null,
@@ -1470,10 +1601,11 @@ async function resolveScope(input, config, client) {
1470
1601
  const appId = optionalNonEmptyString(input.appId) || config.defaultAppId;
1471
1602
  const sessionId = optionalNonEmptyString(input.sessionId);
1472
1603
  if (appId && !sessionId) {
1604
+ const serviceName = optionalNonEmptyString(input.serviceName);
1473
1605
  const requestedEnvironment = optionalNonEmptyString(input.environment);
1474
1606
  const state = normalizePolicyState(input.state);
1475
1607
  const query = optionalNonEmptyString(input.query) || '';
1476
- const privacyConfig = await client.requestApi(`v1/apps/${encodeURIComponent(appId)}/privacy`, {
1608
+ const privacyConfig = await client.requestApi(buildPrivacyApiPath(appId, serviceName), {
1477
1609
  method: 'GET',
1478
1610
  headers: input.tenantId ? { 'x-tenant-id': input.tenantId } : undefined,
1479
1611
  });
@@ -1485,6 +1617,7 @@ async function resolveScope(input, config, client) {
1485
1617
  selection?.environmentId ?? requestedEnvironment,
1486
1618
  query,
1487
1619
  input.openUi === true,
1620
+ serviceName,
1488
1621
  );
1489
1622
  if (ui.openRequested) {
1490
1623
  try {
@@ -1499,6 +1632,7 @@ async function resolveScope(input, config, client) {
1499
1632
  status: selection ? 'ok' : 'not_found',
1500
1633
  kind: 'app_privacy_scope',
1501
1634
  appId,
1635
+ serviceName: serviceName ?? null,
1502
1636
  tenantId: optionalNonEmptyString(input.tenantId) || config.defaultTenantId || null,
1503
1637
  environment: selection?.environmentId ?? requestedEnvironment ?? null,
1504
1638
  state: selection?.state ?? state,
@@ -1559,6 +1693,7 @@ async function checkPrivacyCoverage(input, config, client) {
1559
1693
  const appIds = normalizeCoverageAppIds(input, config);
1560
1694
  const state = normalizePolicyState(input.state);
1561
1695
  const requestedEnvironment = optionalNonEmptyString(input.environment);
1696
+ const serviceName = optionalNonEmptyString(input.serviceName);
1562
1697
  const topic = normalizeCoverageTopic(input);
1563
1698
  const query = queryForCoverageTopic(topic);
1564
1699
  const includeTechnical = input.includeTechnical === true;
@@ -1566,7 +1701,7 @@ async function checkPrivacyCoverage(input, config, client) {
1566
1701
 
1567
1702
  for (const appId of appIds) {
1568
1703
  try {
1569
- const privacyConfig = await client.requestApi(`v1/apps/${encodeURIComponent(appId)}/privacy`, {
1704
+ const privacyConfig = await client.requestApi(buildPrivacyApiPath(appId, serviceName), {
1570
1705
  method: 'GET',
1571
1706
  headers: input.tenantId ? { 'x-tenant-id': input.tenantId } : undefined,
1572
1707
  });
@@ -1578,7 +1713,7 @@ async function checkPrivacyCoverage(input, config, client) {
1578
1713
  environment: requestedEnvironment ?? null,
1579
1714
  state,
1580
1715
  summary: 'No pending or approved privacy policy was found for this app/environment.',
1581
- ui: buildPrivacyStudioUi(config, appId, requestedEnvironment, query, false),
1716
+ ui: buildPrivacyStudioUi(config, appId, requestedEnvironment, query, false, serviceName),
1582
1717
  matches: [],
1583
1718
  needsReviewReasons: ['No policy found.'],
1584
1719
  });
@@ -1598,13 +1733,20 @@ async function checkPrivacyCoverage(input, config, client) {
1598
1733
  ? assessPolicyBlockerCoverage(matchedStatements, contradictions)
1599
1734
  : assessCoverage(matchedStatements, contradictions);
1600
1735
  const evidence = buildCoverageEvidence(matchedStatements, contradictions, { includeTechnical });
1601
- const ui = buildPrivacyStudioUi(config, appId, selection.environmentId, query, input.openUi === true);
1736
+ const ui = buildPrivacyStudioUi(
1737
+ config,
1738
+ appId,
1739
+ selection.environmentId,
1740
+ query,
1741
+ input.openUi === true,
1742
+ serviceName,
1743
+ );
1602
1744
  const policyBlockers = buildPolicyBlockerSummaries(
1603
1745
  blockerQuery ? matchedStatements : matchedStatements.filter(isReviewBlockerStatement),
1604
1746
  config,
1605
1747
  appId,
1606
1748
  selection.environmentId,
1607
- { includeTechnical },
1749
+ { includeTechnical, serviceName },
1608
1750
  );
1609
1751
  if (ui.openRequested) {
1610
1752
  try {
@@ -1618,6 +1760,7 @@ async function checkPrivacyCoverage(input, config, client) {
1618
1760
 
1619
1761
  results.push({
1620
1762
  appId,
1763
+ serviceName: serviceName ?? null,
1621
1764
  environment: selection.environmentId,
1622
1765
  state: selection.state,
1623
1766
  inspectedPolicy: {
@@ -1647,7 +1790,7 @@ async function checkPrivacyCoverage(input, config, client) {
1647
1790
  environment: requestedEnvironment ?? null,
1648
1791
  state,
1649
1792
  summary: error instanceof Error ? error.message : String(error),
1650
- ui: buildPrivacyStudioUi(config, appId, requestedEnvironment, query, false),
1793
+ ui: buildPrivacyStudioUi(config, appId, requestedEnvironment, query, false, serviceName),
1651
1794
  matches: [],
1652
1795
  needsReviewReasons: ['Could not inspect policy because the API request failed.'],
1653
1796
  });
@@ -1666,6 +1809,7 @@ async function checkPrivacyCoverage(input, config, client) {
1666
1809
  inspected: {
1667
1810
  requestedEnvironment: requestedEnvironment ?? null,
1668
1811
  requestedState: state,
1812
+ serviceName: serviceName ?? null,
1669
1813
  appCount: appIds.length,
1670
1814
  },
1671
1815
  summary: summarizeCoverageResults({ safe, needsReview, notCovered, errors }),
@@ -1989,9 +2133,10 @@ async function inspectPrivacyPolicy(input, config, client) {
1989
2133
  throw rpcError(-32603, 'This MCP client cannot fetch app privacy policies');
1990
2134
  }
1991
2135
 
2136
+ const serviceName = optionalNonEmptyString(input.serviceName);
1992
2137
  const requestedEnvironment = optionalNonEmptyString(input.environment);
1993
2138
  const state = normalizePolicyState(input.state);
1994
- const privacyConfig = await client.requestApi(`v1/apps/${encodeURIComponent(appId)}/privacy`, {
2139
+ const privacyConfig = await client.requestApi(buildPrivacyApiPath(appId, serviceName), {
1995
2140
  method: 'GET',
1996
2141
  headers: input.tenantId ? { 'x-tenant-id': input.tenantId } : undefined,
1997
2142
  });
@@ -2000,10 +2145,18 @@ async function inspectPrivacyPolicy(input, config, client) {
2000
2145
  return {
2001
2146
  status: 'not_found',
2002
2147
  appId,
2148
+ serviceName: serviceName ?? null,
2003
2149
  environment: requestedEnvironment ?? null,
2004
2150
  state,
2005
2151
  message: 'No pending or approved privacy policy exists for the requested app/environment.',
2006
- ui: buildPrivacyStudioUi(config, appId, requestedEnvironment, policyInspectionQuery(input), input.openUi === true),
2152
+ ui: buildPrivacyStudioUi(
2153
+ config,
2154
+ appId,
2155
+ requestedEnvironment,
2156
+ policyInspectionQuery(input),
2157
+ input.openUi === true,
2158
+ serviceName,
2159
+ ),
2007
2160
  };
2008
2161
  }
2009
2162
 
@@ -2015,7 +2168,14 @@ async function inspectPrivacyPolicy(input, config, client) {
2015
2168
  const matchedLeaves = new Set(matches.flatMap((match) => statementTargetLeaves(match.statement)));
2016
2169
  const contradictions = findSameLeafPolicyContradictions(statements, matchedLeaves);
2017
2170
  const uiQuery = query || contradictions[0]?.leaf || '';
2018
- const ui = buildPrivacyStudioUi(config, appId, selection.environmentId, uiQuery, input.openUi === true);
2171
+ const ui = buildPrivacyStudioUi(
2172
+ config,
2173
+ appId,
2174
+ selection.environmentId,
2175
+ uiQuery,
2176
+ input.openUi === true,
2177
+ serviceName,
2178
+ );
2019
2179
  const blockerStatements = blockerQuery
2020
2180
  ? matches.map((match) => match.statement).filter(isReviewBlockerStatement)
2021
2181
  : matches.map((match) => match.statement).filter(isReviewBlockerStatement);
@@ -2024,12 +2184,13 @@ async function inspectPrivacyPolicy(input, config, client) {
2024
2184
  config,
2025
2185
  appId,
2026
2186
  selection.environmentId,
2027
- { includeTechnical },
2187
+ { includeTechnical, serviceName },
2028
2188
  );
2029
2189
  const blockerSet = policyBlockers.length
2030
2190
  ? rememberPrivacyBlockerSet({
2031
2191
  tenantId: optionalNonEmptyString(input.tenantId) || config.defaultTenantId || null,
2032
2192
  appId,
2193
+ serviceName,
2033
2194
  environment: selection.environmentId,
2034
2195
  state: selection.state,
2035
2196
  query,
@@ -2049,6 +2210,7 @@ async function inspectPrivacyPolicy(input, config, client) {
2049
2210
  return {
2050
2211
  status: 'ok',
2051
2212
  appId,
2213
+ serviceName: serviceName ?? null,
2052
2214
  environment: selection.environmentId,
2053
2215
  state: selection.state,
2054
2216
  generatedAt: selection.record?.policy?.generatedAt ?? selection.record?.generatedAt ?? null,
@@ -2080,12 +2242,13 @@ async function updatePrivacyPolicy(input, config, client) {
2080
2242
  throw rpcError(-32603, 'This MCP client cannot fetch or update app privacy policies');
2081
2243
  }
2082
2244
 
2245
+ const serviceName = optionalNonEmptyString(input.serviceName);
2083
2246
  const requestedEnvironment = optionalNonEmptyString(input.environment);
2084
2247
  const state = normalizePolicyState(input.state);
2085
2248
  const decision = normalizePolicyUpdateDecision(input.decision);
2086
2249
  const apply = input.apply === true;
2087
2250
  const approve = input.approve === true;
2088
- const privacyConfig = await client.requestApi(`v1/apps/${encodeURIComponent(appId)}/privacy`, {
2251
+ const privacyConfig = await client.requestApi(buildPrivacyApiPath(appId, serviceName), {
2089
2252
  method: 'GET',
2090
2253
  headers: input.tenantId ? { 'x-tenant-id': input.tenantId } : undefined,
2091
2254
  });
@@ -2094,10 +2257,18 @@ async function updatePrivacyPolicy(input, config, client) {
2094
2257
  return {
2095
2258
  status: 'not_found',
2096
2259
  appId,
2260
+ serviceName: serviceName ?? null,
2097
2261
  environment: requestedEnvironment ?? null,
2098
2262
  state,
2099
2263
  summary: 'No pending or approved privacy policy exists for the requested app/environment.',
2100
- ui: buildPrivacyStudioUi(config, appId, requestedEnvironment, policyInspectionQuery(input), input.openUi === true),
2264
+ ui: buildPrivacyStudioUi(
2265
+ config,
2266
+ appId,
2267
+ requestedEnvironment,
2268
+ policyInspectionQuery(input),
2269
+ input.openUi === true,
2270
+ serviceName,
2271
+ ),
2101
2272
  };
2102
2273
  }
2103
2274
 
@@ -2106,6 +2277,7 @@ async function updatePrivacyPolicy(input, config, client) {
2106
2277
  const blockerSetSelection = resolvePrivacyBlockerSetSelection(input, {
2107
2278
  tenantId: optionalNonEmptyString(input.tenantId) || config.defaultTenantId || null,
2108
2279
  appId,
2280
+ serviceName,
2109
2281
  environment: selection.environmentId,
2110
2282
  state: selection.state,
2111
2283
  });
@@ -2114,10 +2286,18 @@ async function updatePrivacyPolicy(input, config, client) {
2114
2286
  status: 'blocked',
2115
2287
  code: blockerSetSelection.error.code,
2116
2288
  appId,
2289
+ serviceName: serviceName ?? null,
2117
2290
  environment: selection.environmentId,
2118
2291
  state: selection.state,
2119
2292
  summary: blockerSetSelection.error.message,
2120
- ui: buildPrivacyStudioUi(config, appId, selection.environmentId, query, input.openUi === true),
2293
+ ui: buildPrivacyStudioUi(
2294
+ config,
2295
+ appId,
2296
+ selection.environmentId,
2297
+ query,
2298
+ input.openUi === true,
2299
+ serviceName,
2300
+ ),
2121
2301
  nextStep: 'Run inspect_privacy_policy or list_privacy_policy_blockers again, then pass the returned blockerSetId.',
2122
2302
  };
2123
2303
  }
@@ -2132,10 +2312,18 @@ async function updatePrivacyPolicy(input, config, client) {
2132
2312
  return {
2133
2313
  status: 'not_found',
2134
2314
  appId,
2315
+ serviceName: serviceName ?? null,
2135
2316
  environment: selection.environmentId,
2136
2317
  state: selection.state,
2137
2318
  summary: 'No matching policy statements were found for the requested update.',
2138
- ui: buildPrivacyStudioUi(config, appId, selection.environmentId, query, input.openUi === true),
2319
+ ui: buildPrivacyStudioUi(
2320
+ config,
2321
+ appId,
2322
+ selection.environmentId,
2323
+ query,
2324
+ input.openUi === true,
2325
+ serviceName,
2326
+ ),
2139
2327
  matches: rankPolicyStatementMatches(statements, input, query).slice(0, 8).map((match) => formatPolicyMatch(match.statement, match.score, { includeTechnical: true })),
2140
2328
  };
2141
2329
  }
@@ -2144,10 +2332,18 @@ async function updatePrivacyPolicy(input, config, client) {
2144
2332
  status: 'blocked',
2145
2333
  code: 'STALE_BLOCKER_SET',
2146
2334
  appId,
2335
+ serviceName: serviceName ?? null,
2147
2336
  environment: selection.environmentId,
2148
2337
  state: selection.state,
2149
2338
  summary: `The blockerSetId references ${blockerSetSelection.statementSids.length} statement(s), but only ${selectedStatements.length} still exist in the current policy.`,
2150
- ui: buildPrivacyStudioUi(config, appId, selection.environmentId, query, input.openUi === true),
2339
+ ui: buildPrivacyStudioUi(
2340
+ config,
2341
+ appId,
2342
+ selection.environmentId,
2343
+ query,
2344
+ input.openUi === true,
2345
+ serviceName,
2346
+ ),
2151
2347
  matches: selectedStatements.map((statement) => formatPolicyMatch(statement, 0, { includeTechnical: true })),
2152
2348
  nextStep: 'Run inspect_privacy_policy or list_privacy_policy_blockers again, then retry with the new blockerSetId.',
2153
2349
  };
@@ -2164,10 +2360,18 @@ async function updatePrivacyPolicy(input, config, client) {
2164
2360
  status: 'blocked',
2165
2361
  code: safety.code,
2166
2362
  appId,
2363
+ serviceName: serviceName ?? null,
2167
2364
  environment: selection.environmentId,
2168
2365
  state: selection.state,
2169
2366
  summary: safety.summary,
2170
- ui: buildPrivacyStudioUi(config, appId, selection.environmentId, query, input.openUi === true),
2367
+ ui: buildPrivacyStudioUi(
2368
+ config,
2369
+ appId,
2370
+ selection.environmentId,
2371
+ query,
2372
+ input.openUi === true,
2373
+ serviceName,
2374
+ ),
2171
2375
  matches: selectedStatements.map((statement) => formatPolicyMatch(statement, 0, { includeTechnical: true })),
2172
2376
  proposed: safeBuildPolicyUpdateDiff(selection.policy, selectedStatements, decision, input.rawTextHints),
2173
2377
  safety,
@@ -2182,6 +2386,7 @@ async function updatePrivacyPolicy(input, config, client) {
2182
2386
  selection.environmentId,
2183
2387
  query || selectedStatements[0]?.when?.selector || '',
2184
2388
  input.openUi === true,
2389
+ serviceName,
2185
2390
  );
2186
2391
  if (ui.openRequested) {
2187
2392
  try {
@@ -2197,6 +2402,7 @@ async function updatePrivacyPolicy(input, config, client) {
2197
2402
  return {
2198
2403
  status: 'dry_run',
2199
2404
  appId,
2405
+ serviceName: serviceName ?? null,
2200
2406
  environment: selection.environmentId,
2201
2407
  state: selection.state,
2202
2408
  summary: `Prepared privacy policy update for ${selectedStatements.length} statement(s). No API write was made because apply=true was not set.`,
@@ -2211,9 +2417,18 @@ async function updatePrivacyPolicy(input, config, client) {
2211
2417
  };
2212
2418
  }
2213
2419
 
2214
- const policyTarget = privacyPolicyTargetForApp(appId, selection.record, selection.policy);
2420
+ const policyTarget = privacyPolicyTargetForApp(
2421
+ appId,
2422
+ serviceName,
2423
+ selection.record,
2424
+ selection.policy,
2425
+ );
2215
2426
  const pendingConfig = await client.requestApi(
2216
- `v1/apps/${encodeURIComponent(appId)}/privacy/environments/${encodeURIComponent(selection.environmentId)}/policy`,
2427
+ buildPrivacyApiPath(
2428
+ appId,
2429
+ serviceName,
2430
+ `/environments/${encodeURIComponent(selection.environmentId)}/policy`,
2431
+ ),
2217
2432
  {
2218
2433
  method: 'PUT',
2219
2434
  headers: input.tenantId ? { 'x-tenant-id': input.tenantId } : undefined,
@@ -2241,7 +2456,11 @@ async function updatePrivacyPolicy(input, config, client) {
2241
2456
  let approvedConfig = null;
2242
2457
  if (approve) {
2243
2458
  approvedConfig = await client.requestApi(
2244
- `v1/apps/${encodeURIComponent(appId)}/privacy/environments/${encodeURIComponent(selection.environmentId)}/pending/approve`,
2459
+ buildPrivacyApiPath(
2460
+ appId,
2461
+ serviceName,
2462
+ `/environments/${encodeURIComponent(selection.environmentId)}/pending/approve`,
2463
+ ),
2245
2464
  {
2246
2465
  method: 'POST',
2247
2466
  headers: input.tenantId ? { 'x-tenant-id': input.tenantId } : undefined,
@@ -2252,6 +2471,7 @@ async function updatePrivacyPolicy(input, config, client) {
2252
2471
  return {
2253
2472
  status: approve ? 'approved' : 'pending',
2254
2473
  appId,
2474
+ serviceName: serviceName ?? null,
2255
2475
  environment: selection.environmentId,
2256
2476
  previousState: selection.state,
2257
2477
  summary: approve
@@ -2280,8 +2500,9 @@ async function approvePrivacyPolicy(input, config, client) {
2280
2500
  throw rpcError(-32603, 'This MCP client cannot approve app privacy policies');
2281
2501
  }
2282
2502
 
2503
+ const serviceName = optionalNonEmptyString(input.serviceName);
2283
2504
  const requestedEnvironment = optionalNonEmptyString(input.environment);
2284
- const privacyConfig = await client.requestApi(`v1/apps/${encodeURIComponent(appId)}/privacy`, {
2505
+ const privacyConfig = await client.requestApi(buildPrivacyApiPath(appId, serviceName), {
2285
2506
  method: 'GET',
2286
2507
  headers: input.tenantId ? { 'x-tenant-id': input.tenantId } : undefined,
2287
2508
  });
@@ -2290,13 +2511,25 @@ async function approvePrivacyPolicy(input, config, client) {
2290
2511
  return {
2291
2512
  status: 'not_found',
2292
2513
  appId,
2514
+ serviceName: serviceName ?? null,
2293
2515
  environment: requestedEnvironment ?? null,
2294
2516
  summary: 'No pending privacy policy exists for the requested app/environment.',
2295
- ui: buildPrivacyStudioUi(config, appId, requestedEnvironment, '', input.openUi === true),
2517
+ ui: buildPrivacyStudioUi(
2518
+ config,
2519
+ appId,
2520
+ requestedEnvironment,
2521
+ '',
2522
+ input.openUi === true,
2523
+ serviceName,
2524
+ ),
2296
2525
  };
2297
2526
  }
2298
2527
  const approvedConfig = await client.requestApi(
2299
- `v1/apps/${encodeURIComponent(appId)}/privacy/environments/${encodeURIComponent(selection.environmentId)}/pending/approve`,
2528
+ buildPrivacyApiPath(
2529
+ appId,
2530
+ serviceName,
2531
+ `/environments/${encodeURIComponent(selection.environmentId)}/pending/approve`,
2532
+ ),
2300
2533
  {
2301
2534
  method: 'POST',
2302
2535
  headers: input.tenantId ? { 'x-tenant-id': input.tenantId } : undefined,
@@ -2305,9 +2538,17 @@ async function approvePrivacyPolicy(input, config, client) {
2305
2538
  return {
2306
2539
  status: 'approved',
2307
2540
  appId,
2541
+ serviceName: serviceName ?? null,
2308
2542
  environment: selection.environmentId,
2309
2543
  summary: 'Approved pending privacy policy through the Repro API validation path.',
2310
- ui: buildPrivacyStudioUi(config, appId, selection.environmentId, '', input.openUi === true),
2544
+ ui: buildPrivacyStudioUi(
2545
+ config,
2546
+ appId,
2547
+ selection.environmentId,
2548
+ '',
2549
+ input.openUi === true,
2550
+ serviceName,
2551
+ ),
2311
2552
  api: {
2312
2553
  approvedEnvironmentCount: Array.isArray(approvedConfig?.environments) ? approvedConfig.environments.length : undefined,
2313
2554
  },
@@ -2415,7 +2656,15 @@ function cleanupExpiredPrivacyBlockerSets(now = Date.now()) {
2415
2656
  }
2416
2657
  }
2417
2658
 
2418
- function rememberPrivacyBlockerSet({ tenantId, appId, environment, state, query, statements }) {
2659
+ function rememberPrivacyBlockerSet({
2660
+ tenantId,
2661
+ appId,
2662
+ serviceName,
2663
+ environment,
2664
+ state,
2665
+ query,
2666
+ statements,
2667
+ }) {
2419
2668
  cleanupExpiredPrivacyBlockerSets();
2420
2669
  const statementSids = mergeUniqueStrings(
2421
2670
  (statements ?? []).map((statement) => String(statement?.sid ?? '')).filter(Boolean),
@@ -2425,6 +2674,7 @@ function rememberPrivacyBlockerSet({ tenantId, appId, environment, state, query,
2425
2674
  v: 1,
2426
2675
  tenantId: tenantId || null,
2427
2676
  appId,
2677
+ serviceName: serviceName || null,
2428
2678
  environment,
2429
2679
  state,
2430
2680
  queryHash: crypto.createHash('sha256').update(query || '').digest('hex').slice(0, 12),
@@ -2439,6 +2689,7 @@ function rememberPrivacyBlockerSet({ tenantId, appId, environment, state, query,
2439
2689
  id,
2440
2690
  tenantId: payload.tenantId,
2441
2691
  appId: payload.appId,
2692
+ serviceName: payload.serviceName,
2442
2693
  environment: payload.environment,
2443
2694
  state: payload.state,
2444
2695
  query: query || null,
@@ -2483,6 +2734,9 @@ function resolvePrivacyBlockerSetSelection(input, expected) {
2483
2734
  }
2484
2735
  const mismatches = [];
2485
2736
  if (record.appId !== expected.appId) mismatches.push(`appId ${record.appId}`);
2737
+ if ((record.serviceName || null) !== (expected.serviceName || null)) {
2738
+ mismatches.push(`serviceName ${record.serviceName || '(app-wide)'}`);
2739
+ }
2486
2740
  if (record.environment !== expected.environment) mismatches.push(`environment ${record.environment}`);
2487
2741
  if (record.state !== expected.state) mismatches.push(`state ${record.state}`);
2488
2742
  if (record.tenantId && expected.tenantId && record.tenantId !== expected.tenantId) {
@@ -2908,15 +3162,21 @@ function mergeUniqueStrings(values) {
2908
3162
  return output;
2909
3163
  }
2910
3164
 
2911
- function privacyPolicyTargetForApp(appId, record, policy) {
3165
+ function privacyPolicyTargetForApp(appId, serviceName, record, policy) {
2912
3166
  const target =
2913
3167
  record?.proposal?.target ??
2914
3168
  policy?.sourceProposal?.target ??
2915
3169
  {};
2916
- return {
3170
+ const result = {
2917
3171
  ...target,
2918
3172
  appId,
2919
3173
  };
3174
+ if (serviceName) {
3175
+ result.serviceName = serviceName;
3176
+ } else {
3177
+ delete result.serviceName;
3178
+ }
3179
+ return result;
2920
3180
  }
2921
3181
 
2922
3182
  function cloneJson(value) {
@@ -3159,7 +3419,14 @@ function buildPolicyBlockerSummaries(statements, config, appId, environment, opt
3159
3419
  const descriptor = describePolicyStatement(statement);
3160
3420
  const review = statement?.meta?.review ?? {};
3161
3421
  const query = descriptor.codeAnchor || descriptor.label || statement?.when?.selector || '';
3162
- const ui = buildPrivacyStudioUi(config, appId, environment, query, false);
3422
+ const ui = buildPrivacyStudioUi(
3423
+ config,
3424
+ appId,
3425
+ environment,
3426
+ query,
3427
+ false,
3428
+ options.serviceName,
3429
+ );
3163
3430
  const blocker = {
3164
3431
  label: descriptor.label,
3165
3432
  codeAnchor: descriptor.codeAnchor,
@@ -3402,7 +3669,22 @@ function summarizeRawTextHints(policy, query) {
3402
3669
  }));
3403
3670
  }
3404
3671
 
3405
- function buildPrivacyStudioUi(config, appId, environment, query, openRequested) {
3672
+ function buildPrivacyApiPath(appId, serviceName, suffix = '') {
3673
+ const path = `v1/apps/${encodeURIComponent(appId)}/privacy${suffix}`;
3674
+ if (!serviceName) {
3675
+ return path;
3676
+ }
3677
+ return `${path}?serviceName=${encodeURIComponent(serviceName)}`;
3678
+ }
3679
+
3680
+ function buildPrivacyStudioUi(
3681
+ config,
3682
+ appId,
3683
+ environment,
3684
+ query,
3685
+ openRequested,
3686
+ serviceName,
3687
+ ) {
3406
3688
  const baseUrl = config.webBaseUrl || deriveWebBaseUrl(config.apiBaseUrl);
3407
3689
  if (!baseUrl) {
3408
3690
  return {
@@ -3415,6 +3697,9 @@ function buildPrivacyStudioUi(config, appId, environment, query, openRequested)
3415
3697
 
3416
3698
  const url = new URL('/admin/privacy', baseUrl);
3417
3699
  url.searchParams.set('service', appId);
3700
+ if (serviceName) {
3701
+ url.searchParams.set('serviceName', serviceName);
3702
+ }
3418
3703
  if (environment) {
3419
3704
  url.searchParams.set('env', environment);
3420
3705
  }