@reproapp/mcp 0.0.9 → 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 +286 -37
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.9",
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
@@ -38,6 +38,7 @@ const LOCAL_TOOL_DEFINITIONS = [
38
38
  properties: {
39
39
  tenantId: { type: 'string' },
40
40
  appId: { type: 'string' },
41
+ serviceName: { type: 'string' },
41
42
  sessionId: { type: 'string' },
42
43
  environment: { type: 'string' },
43
44
  state: { type: 'string', enum: ['active', 'pending', 'approved'] },
@@ -56,6 +57,7 @@ const LOCAL_TOOL_DEFINITIONS = [
56
57
  properties: {
57
58
  tenantId: { type: 'string' },
58
59
  appId: { type: 'string' },
60
+ serviceName: { type: 'string' },
59
61
  appIds: {
60
62
  type: 'array',
61
63
  items: { type: 'string' },
@@ -79,6 +81,7 @@ const LOCAL_TOOL_DEFINITIONS = [
79
81
  properties: {
80
82
  tenantId: { type: 'string' },
81
83
  appId: { type: 'string' },
84
+ serviceName: { type: 'string' },
82
85
  environment: { type: 'string' },
83
86
  state: { type: 'string', enum: ['active', 'pending', 'approved'] },
84
87
  query: { type: 'string' },
@@ -101,6 +104,7 @@ const LOCAL_TOOL_DEFINITIONS = [
101
104
  properties: {
102
105
  tenantId: { type: 'string' },
103
106
  appId: { type: 'string' },
107
+ serviceName: { type: 'string' },
104
108
  environment: { type: 'string' },
105
109
  state: { type: 'string', enum: ['active', 'pending', 'approved'] },
106
110
  query: { type: 'string' },
@@ -119,6 +123,7 @@ const LOCAL_TOOL_DEFINITIONS = [
119
123
  properties: {
120
124
  tenantId: { type: 'string' },
121
125
  appId: { type: 'string' },
126
+ serviceName: { type: 'string' },
122
127
  environment: { type: 'string' },
123
128
  state: { type: 'string', enum: ['active', 'pending', 'approved'] },
124
129
  query: { type: 'string' },
@@ -170,6 +175,7 @@ const LOCAL_TOOL_DEFINITIONS = [
170
175
  properties: {
171
176
  tenantId: { type: 'string' },
172
177
  appId: { type: 'string' },
178
+ serviceName: { type: 'string' },
173
179
  environment: { type: 'string' },
174
180
  openUi: { type: 'boolean' },
175
181
  },
@@ -202,6 +208,10 @@ const TOOL_DESCRIPTION_OVERRIDES = {
202
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.',
203
209
  get_request:
204
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.',
205
215
  get_requests_relative_to_anchor:
206
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.',
207
217
  get_db_changes:
@@ -1262,7 +1272,7 @@ function inputWithExactCapturedPayloads(name, input) {
1262
1272
  }
1263
1273
 
1264
1274
  async function expandLinkedCapturedPayloads(name, response, client, originalInput = {}) {
1265
- if (name !== 'get_kafka_messages') {
1275
+ if (name === 'get_captured_value') {
1266
1276
  return response;
1267
1277
  }
1268
1278
 
@@ -1271,20 +1281,41 @@ async function expandLinkedCapturedPayloads(name, response, client, originalInpu
1271
1281
 
1272
1282
  while (true) {
1273
1283
  const links = collectCapturedPayloadLinks(root)
1274
- .filter((entry) => !visited.has(capturedPayloadLinkKey(entry.link)));
1275
- 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) {
1276
1289
  return root;
1277
1290
  }
1278
1291
 
1279
- for (const { link } of links) {
1280
- const key = capturedPayloadLinkKey(link);
1292
+ for (const expansion of expansions) {
1293
+ const key = capturedPayloadExpansionKey(expansion);
1281
1294
  visited.add(key);
1282
1295
 
1283
- const toolName = typeof link.tool === 'string' ? link.tool : '';
1284
- if (toolName !== 'get_kafka_messages') {
1285
- 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;
1286
1315
  }
1287
1316
 
1317
+ const { link } = expansion;
1318
+ const toolName = typeof link.tool === 'string' ? link.tool : '';
1288
1319
  const linkArgs = link.arguments && typeof link.arguments === 'object' && !Array.isArray(link.arguments)
1289
1320
  ? link.arguments
1290
1321
  : {};
@@ -1335,8 +1366,72 @@ function collectCapturedPayloadLinks(value, out = []) {
1335
1366
  return out;
1336
1367
  }
1337
1368
 
1338
- 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;
1339
1433
  return JSON.stringify({
1434
+ kind: 'mcp-link',
1340
1435
  tool: link?.tool ?? null,
1341
1436
  path: link?.path ?? null,
1342
1437
  arguments: link?.arguments ?? null,
@@ -1506,10 +1601,11 @@ async function resolveScope(input, config, client) {
1506
1601
  const appId = optionalNonEmptyString(input.appId) || config.defaultAppId;
1507
1602
  const sessionId = optionalNonEmptyString(input.sessionId);
1508
1603
  if (appId && !sessionId) {
1604
+ const serviceName = optionalNonEmptyString(input.serviceName);
1509
1605
  const requestedEnvironment = optionalNonEmptyString(input.environment);
1510
1606
  const state = normalizePolicyState(input.state);
1511
1607
  const query = optionalNonEmptyString(input.query) || '';
1512
- const privacyConfig = await client.requestApi(`v1/apps/${encodeURIComponent(appId)}/privacy`, {
1608
+ const privacyConfig = await client.requestApi(buildPrivacyApiPath(appId, serviceName), {
1513
1609
  method: 'GET',
1514
1610
  headers: input.tenantId ? { 'x-tenant-id': input.tenantId } : undefined,
1515
1611
  });
@@ -1521,6 +1617,7 @@ async function resolveScope(input, config, client) {
1521
1617
  selection?.environmentId ?? requestedEnvironment,
1522
1618
  query,
1523
1619
  input.openUi === true,
1620
+ serviceName,
1524
1621
  );
1525
1622
  if (ui.openRequested) {
1526
1623
  try {
@@ -1535,6 +1632,7 @@ async function resolveScope(input, config, client) {
1535
1632
  status: selection ? 'ok' : 'not_found',
1536
1633
  kind: 'app_privacy_scope',
1537
1634
  appId,
1635
+ serviceName: serviceName ?? null,
1538
1636
  tenantId: optionalNonEmptyString(input.tenantId) || config.defaultTenantId || null,
1539
1637
  environment: selection?.environmentId ?? requestedEnvironment ?? null,
1540
1638
  state: selection?.state ?? state,
@@ -1595,6 +1693,7 @@ async function checkPrivacyCoverage(input, config, client) {
1595
1693
  const appIds = normalizeCoverageAppIds(input, config);
1596
1694
  const state = normalizePolicyState(input.state);
1597
1695
  const requestedEnvironment = optionalNonEmptyString(input.environment);
1696
+ const serviceName = optionalNonEmptyString(input.serviceName);
1598
1697
  const topic = normalizeCoverageTopic(input);
1599
1698
  const query = queryForCoverageTopic(topic);
1600
1699
  const includeTechnical = input.includeTechnical === true;
@@ -1602,7 +1701,7 @@ async function checkPrivacyCoverage(input, config, client) {
1602
1701
 
1603
1702
  for (const appId of appIds) {
1604
1703
  try {
1605
- const privacyConfig = await client.requestApi(`v1/apps/${encodeURIComponent(appId)}/privacy`, {
1704
+ const privacyConfig = await client.requestApi(buildPrivacyApiPath(appId, serviceName), {
1606
1705
  method: 'GET',
1607
1706
  headers: input.tenantId ? { 'x-tenant-id': input.tenantId } : undefined,
1608
1707
  });
@@ -1614,7 +1713,7 @@ async function checkPrivacyCoverage(input, config, client) {
1614
1713
  environment: requestedEnvironment ?? null,
1615
1714
  state,
1616
1715
  summary: 'No pending or approved privacy policy was found for this app/environment.',
1617
- ui: buildPrivacyStudioUi(config, appId, requestedEnvironment, query, false),
1716
+ ui: buildPrivacyStudioUi(config, appId, requestedEnvironment, query, false, serviceName),
1618
1717
  matches: [],
1619
1718
  needsReviewReasons: ['No policy found.'],
1620
1719
  });
@@ -1634,13 +1733,20 @@ async function checkPrivacyCoverage(input, config, client) {
1634
1733
  ? assessPolicyBlockerCoverage(matchedStatements, contradictions)
1635
1734
  : assessCoverage(matchedStatements, contradictions);
1636
1735
  const evidence = buildCoverageEvidence(matchedStatements, contradictions, { includeTechnical });
1637
- 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
+ );
1638
1744
  const policyBlockers = buildPolicyBlockerSummaries(
1639
1745
  blockerQuery ? matchedStatements : matchedStatements.filter(isReviewBlockerStatement),
1640
1746
  config,
1641
1747
  appId,
1642
1748
  selection.environmentId,
1643
- { includeTechnical },
1749
+ { includeTechnical, serviceName },
1644
1750
  );
1645
1751
  if (ui.openRequested) {
1646
1752
  try {
@@ -1654,6 +1760,7 @@ async function checkPrivacyCoverage(input, config, client) {
1654
1760
 
1655
1761
  results.push({
1656
1762
  appId,
1763
+ serviceName: serviceName ?? null,
1657
1764
  environment: selection.environmentId,
1658
1765
  state: selection.state,
1659
1766
  inspectedPolicy: {
@@ -1683,7 +1790,7 @@ async function checkPrivacyCoverage(input, config, client) {
1683
1790
  environment: requestedEnvironment ?? null,
1684
1791
  state,
1685
1792
  summary: error instanceof Error ? error.message : String(error),
1686
- ui: buildPrivacyStudioUi(config, appId, requestedEnvironment, query, false),
1793
+ ui: buildPrivacyStudioUi(config, appId, requestedEnvironment, query, false, serviceName),
1687
1794
  matches: [],
1688
1795
  needsReviewReasons: ['Could not inspect policy because the API request failed.'],
1689
1796
  });
@@ -1702,6 +1809,7 @@ async function checkPrivacyCoverage(input, config, client) {
1702
1809
  inspected: {
1703
1810
  requestedEnvironment: requestedEnvironment ?? null,
1704
1811
  requestedState: state,
1812
+ serviceName: serviceName ?? null,
1705
1813
  appCount: appIds.length,
1706
1814
  },
1707
1815
  summary: summarizeCoverageResults({ safe, needsReview, notCovered, errors }),
@@ -2025,9 +2133,10 @@ async function inspectPrivacyPolicy(input, config, client) {
2025
2133
  throw rpcError(-32603, 'This MCP client cannot fetch app privacy policies');
2026
2134
  }
2027
2135
 
2136
+ const serviceName = optionalNonEmptyString(input.serviceName);
2028
2137
  const requestedEnvironment = optionalNonEmptyString(input.environment);
2029
2138
  const state = normalizePolicyState(input.state);
2030
- const privacyConfig = await client.requestApi(`v1/apps/${encodeURIComponent(appId)}/privacy`, {
2139
+ const privacyConfig = await client.requestApi(buildPrivacyApiPath(appId, serviceName), {
2031
2140
  method: 'GET',
2032
2141
  headers: input.tenantId ? { 'x-tenant-id': input.tenantId } : undefined,
2033
2142
  });
@@ -2036,10 +2145,18 @@ async function inspectPrivacyPolicy(input, config, client) {
2036
2145
  return {
2037
2146
  status: 'not_found',
2038
2147
  appId,
2148
+ serviceName: serviceName ?? null,
2039
2149
  environment: requestedEnvironment ?? null,
2040
2150
  state,
2041
2151
  message: 'No pending or approved privacy policy exists for the requested app/environment.',
2042
- 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
+ ),
2043
2160
  };
2044
2161
  }
2045
2162
 
@@ -2051,7 +2168,14 @@ async function inspectPrivacyPolicy(input, config, client) {
2051
2168
  const matchedLeaves = new Set(matches.flatMap((match) => statementTargetLeaves(match.statement)));
2052
2169
  const contradictions = findSameLeafPolicyContradictions(statements, matchedLeaves);
2053
2170
  const uiQuery = query || contradictions[0]?.leaf || '';
2054
- 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
+ );
2055
2179
  const blockerStatements = blockerQuery
2056
2180
  ? matches.map((match) => match.statement).filter(isReviewBlockerStatement)
2057
2181
  : matches.map((match) => match.statement).filter(isReviewBlockerStatement);
@@ -2060,12 +2184,13 @@ async function inspectPrivacyPolicy(input, config, client) {
2060
2184
  config,
2061
2185
  appId,
2062
2186
  selection.environmentId,
2063
- { includeTechnical },
2187
+ { includeTechnical, serviceName },
2064
2188
  );
2065
2189
  const blockerSet = policyBlockers.length
2066
2190
  ? rememberPrivacyBlockerSet({
2067
2191
  tenantId: optionalNonEmptyString(input.tenantId) || config.defaultTenantId || null,
2068
2192
  appId,
2193
+ serviceName,
2069
2194
  environment: selection.environmentId,
2070
2195
  state: selection.state,
2071
2196
  query,
@@ -2085,6 +2210,7 @@ async function inspectPrivacyPolicy(input, config, client) {
2085
2210
  return {
2086
2211
  status: 'ok',
2087
2212
  appId,
2213
+ serviceName: serviceName ?? null,
2088
2214
  environment: selection.environmentId,
2089
2215
  state: selection.state,
2090
2216
  generatedAt: selection.record?.policy?.generatedAt ?? selection.record?.generatedAt ?? null,
@@ -2116,12 +2242,13 @@ async function updatePrivacyPolicy(input, config, client) {
2116
2242
  throw rpcError(-32603, 'This MCP client cannot fetch or update app privacy policies');
2117
2243
  }
2118
2244
 
2245
+ const serviceName = optionalNonEmptyString(input.serviceName);
2119
2246
  const requestedEnvironment = optionalNonEmptyString(input.environment);
2120
2247
  const state = normalizePolicyState(input.state);
2121
2248
  const decision = normalizePolicyUpdateDecision(input.decision);
2122
2249
  const apply = input.apply === true;
2123
2250
  const approve = input.approve === true;
2124
- const privacyConfig = await client.requestApi(`v1/apps/${encodeURIComponent(appId)}/privacy`, {
2251
+ const privacyConfig = await client.requestApi(buildPrivacyApiPath(appId, serviceName), {
2125
2252
  method: 'GET',
2126
2253
  headers: input.tenantId ? { 'x-tenant-id': input.tenantId } : undefined,
2127
2254
  });
@@ -2130,10 +2257,18 @@ async function updatePrivacyPolicy(input, config, client) {
2130
2257
  return {
2131
2258
  status: 'not_found',
2132
2259
  appId,
2260
+ serviceName: serviceName ?? null,
2133
2261
  environment: requestedEnvironment ?? null,
2134
2262
  state,
2135
2263
  summary: 'No pending or approved privacy policy exists for the requested app/environment.',
2136
- 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
+ ),
2137
2272
  };
2138
2273
  }
2139
2274
 
@@ -2142,6 +2277,7 @@ async function updatePrivacyPolicy(input, config, client) {
2142
2277
  const blockerSetSelection = resolvePrivacyBlockerSetSelection(input, {
2143
2278
  tenantId: optionalNonEmptyString(input.tenantId) || config.defaultTenantId || null,
2144
2279
  appId,
2280
+ serviceName,
2145
2281
  environment: selection.environmentId,
2146
2282
  state: selection.state,
2147
2283
  });
@@ -2150,10 +2286,18 @@ async function updatePrivacyPolicy(input, config, client) {
2150
2286
  status: 'blocked',
2151
2287
  code: blockerSetSelection.error.code,
2152
2288
  appId,
2289
+ serviceName: serviceName ?? null,
2153
2290
  environment: selection.environmentId,
2154
2291
  state: selection.state,
2155
2292
  summary: blockerSetSelection.error.message,
2156
- 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
+ ),
2157
2301
  nextStep: 'Run inspect_privacy_policy or list_privacy_policy_blockers again, then pass the returned blockerSetId.',
2158
2302
  };
2159
2303
  }
@@ -2168,10 +2312,18 @@ async function updatePrivacyPolicy(input, config, client) {
2168
2312
  return {
2169
2313
  status: 'not_found',
2170
2314
  appId,
2315
+ serviceName: serviceName ?? null,
2171
2316
  environment: selection.environmentId,
2172
2317
  state: selection.state,
2173
2318
  summary: 'No matching policy statements were found for the requested update.',
2174
- 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
+ ),
2175
2327
  matches: rankPolicyStatementMatches(statements, input, query).slice(0, 8).map((match) => formatPolicyMatch(match.statement, match.score, { includeTechnical: true })),
2176
2328
  };
2177
2329
  }
@@ -2180,10 +2332,18 @@ async function updatePrivacyPolicy(input, config, client) {
2180
2332
  status: 'blocked',
2181
2333
  code: 'STALE_BLOCKER_SET',
2182
2334
  appId,
2335
+ serviceName: serviceName ?? null,
2183
2336
  environment: selection.environmentId,
2184
2337
  state: selection.state,
2185
2338
  summary: `The blockerSetId references ${blockerSetSelection.statementSids.length} statement(s), but only ${selectedStatements.length} still exist in the current policy.`,
2186
- 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
+ ),
2187
2347
  matches: selectedStatements.map((statement) => formatPolicyMatch(statement, 0, { includeTechnical: true })),
2188
2348
  nextStep: 'Run inspect_privacy_policy or list_privacy_policy_blockers again, then retry with the new blockerSetId.',
2189
2349
  };
@@ -2200,10 +2360,18 @@ async function updatePrivacyPolicy(input, config, client) {
2200
2360
  status: 'blocked',
2201
2361
  code: safety.code,
2202
2362
  appId,
2363
+ serviceName: serviceName ?? null,
2203
2364
  environment: selection.environmentId,
2204
2365
  state: selection.state,
2205
2366
  summary: safety.summary,
2206
- 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
+ ),
2207
2375
  matches: selectedStatements.map((statement) => formatPolicyMatch(statement, 0, { includeTechnical: true })),
2208
2376
  proposed: safeBuildPolicyUpdateDiff(selection.policy, selectedStatements, decision, input.rawTextHints),
2209
2377
  safety,
@@ -2218,6 +2386,7 @@ async function updatePrivacyPolicy(input, config, client) {
2218
2386
  selection.environmentId,
2219
2387
  query || selectedStatements[0]?.when?.selector || '',
2220
2388
  input.openUi === true,
2389
+ serviceName,
2221
2390
  );
2222
2391
  if (ui.openRequested) {
2223
2392
  try {
@@ -2233,6 +2402,7 @@ async function updatePrivacyPolicy(input, config, client) {
2233
2402
  return {
2234
2403
  status: 'dry_run',
2235
2404
  appId,
2405
+ serviceName: serviceName ?? null,
2236
2406
  environment: selection.environmentId,
2237
2407
  state: selection.state,
2238
2408
  summary: `Prepared privacy policy update for ${selectedStatements.length} statement(s). No API write was made because apply=true was not set.`,
@@ -2247,9 +2417,18 @@ async function updatePrivacyPolicy(input, config, client) {
2247
2417
  };
2248
2418
  }
2249
2419
 
2250
- const policyTarget = privacyPolicyTargetForApp(appId, selection.record, selection.policy);
2420
+ const policyTarget = privacyPolicyTargetForApp(
2421
+ appId,
2422
+ serviceName,
2423
+ selection.record,
2424
+ selection.policy,
2425
+ );
2251
2426
  const pendingConfig = await client.requestApi(
2252
- `v1/apps/${encodeURIComponent(appId)}/privacy/environments/${encodeURIComponent(selection.environmentId)}/policy`,
2427
+ buildPrivacyApiPath(
2428
+ appId,
2429
+ serviceName,
2430
+ `/environments/${encodeURIComponent(selection.environmentId)}/policy`,
2431
+ ),
2253
2432
  {
2254
2433
  method: 'PUT',
2255
2434
  headers: input.tenantId ? { 'x-tenant-id': input.tenantId } : undefined,
@@ -2277,7 +2456,11 @@ async function updatePrivacyPolicy(input, config, client) {
2277
2456
  let approvedConfig = null;
2278
2457
  if (approve) {
2279
2458
  approvedConfig = await client.requestApi(
2280
- `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
+ ),
2281
2464
  {
2282
2465
  method: 'POST',
2283
2466
  headers: input.tenantId ? { 'x-tenant-id': input.tenantId } : undefined,
@@ -2288,6 +2471,7 @@ async function updatePrivacyPolicy(input, config, client) {
2288
2471
  return {
2289
2472
  status: approve ? 'approved' : 'pending',
2290
2473
  appId,
2474
+ serviceName: serviceName ?? null,
2291
2475
  environment: selection.environmentId,
2292
2476
  previousState: selection.state,
2293
2477
  summary: approve
@@ -2316,8 +2500,9 @@ async function approvePrivacyPolicy(input, config, client) {
2316
2500
  throw rpcError(-32603, 'This MCP client cannot approve app privacy policies');
2317
2501
  }
2318
2502
 
2503
+ const serviceName = optionalNonEmptyString(input.serviceName);
2319
2504
  const requestedEnvironment = optionalNonEmptyString(input.environment);
2320
- const privacyConfig = await client.requestApi(`v1/apps/${encodeURIComponent(appId)}/privacy`, {
2505
+ const privacyConfig = await client.requestApi(buildPrivacyApiPath(appId, serviceName), {
2321
2506
  method: 'GET',
2322
2507
  headers: input.tenantId ? { 'x-tenant-id': input.tenantId } : undefined,
2323
2508
  });
@@ -2326,13 +2511,25 @@ async function approvePrivacyPolicy(input, config, client) {
2326
2511
  return {
2327
2512
  status: 'not_found',
2328
2513
  appId,
2514
+ serviceName: serviceName ?? null,
2329
2515
  environment: requestedEnvironment ?? null,
2330
2516
  summary: 'No pending privacy policy exists for the requested app/environment.',
2331
- 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
+ ),
2332
2525
  };
2333
2526
  }
2334
2527
  const approvedConfig = await client.requestApi(
2335
- `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
+ ),
2336
2533
  {
2337
2534
  method: 'POST',
2338
2535
  headers: input.tenantId ? { 'x-tenant-id': input.tenantId } : undefined,
@@ -2341,9 +2538,17 @@ async function approvePrivacyPolicy(input, config, client) {
2341
2538
  return {
2342
2539
  status: 'approved',
2343
2540
  appId,
2541
+ serviceName: serviceName ?? null,
2344
2542
  environment: selection.environmentId,
2345
2543
  summary: 'Approved pending privacy policy through the Repro API validation path.',
2346
- 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
+ ),
2347
2552
  api: {
2348
2553
  approvedEnvironmentCount: Array.isArray(approvedConfig?.environments) ? approvedConfig.environments.length : undefined,
2349
2554
  },
@@ -2451,7 +2656,15 @@ function cleanupExpiredPrivacyBlockerSets(now = Date.now()) {
2451
2656
  }
2452
2657
  }
2453
2658
 
2454
- 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
+ }) {
2455
2668
  cleanupExpiredPrivacyBlockerSets();
2456
2669
  const statementSids = mergeUniqueStrings(
2457
2670
  (statements ?? []).map((statement) => String(statement?.sid ?? '')).filter(Boolean),
@@ -2461,6 +2674,7 @@ function rememberPrivacyBlockerSet({ tenantId, appId, environment, state, query,
2461
2674
  v: 1,
2462
2675
  tenantId: tenantId || null,
2463
2676
  appId,
2677
+ serviceName: serviceName || null,
2464
2678
  environment,
2465
2679
  state,
2466
2680
  queryHash: crypto.createHash('sha256').update(query || '').digest('hex').slice(0, 12),
@@ -2475,6 +2689,7 @@ function rememberPrivacyBlockerSet({ tenantId, appId, environment, state, query,
2475
2689
  id,
2476
2690
  tenantId: payload.tenantId,
2477
2691
  appId: payload.appId,
2692
+ serviceName: payload.serviceName,
2478
2693
  environment: payload.environment,
2479
2694
  state: payload.state,
2480
2695
  query: query || null,
@@ -2519,6 +2734,9 @@ function resolvePrivacyBlockerSetSelection(input, expected) {
2519
2734
  }
2520
2735
  const mismatches = [];
2521
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
+ }
2522
2740
  if (record.environment !== expected.environment) mismatches.push(`environment ${record.environment}`);
2523
2741
  if (record.state !== expected.state) mismatches.push(`state ${record.state}`);
2524
2742
  if (record.tenantId && expected.tenantId && record.tenantId !== expected.tenantId) {
@@ -2944,15 +3162,21 @@ function mergeUniqueStrings(values) {
2944
3162
  return output;
2945
3163
  }
2946
3164
 
2947
- function privacyPolicyTargetForApp(appId, record, policy) {
3165
+ function privacyPolicyTargetForApp(appId, serviceName, record, policy) {
2948
3166
  const target =
2949
3167
  record?.proposal?.target ??
2950
3168
  policy?.sourceProposal?.target ??
2951
3169
  {};
2952
- return {
3170
+ const result = {
2953
3171
  ...target,
2954
3172
  appId,
2955
3173
  };
3174
+ if (serviceName) {
3175
+ result.serviceName = serviceName;
3176
+ } else {
3177
+ delete result.serviceName;
3178
+ }
3179
+ return result;
2956
3180
  }
2957
3181
 
2958
3182
  function cloneJson(value) {
@@ -3195,7 +3419,14 @@ function buildPolicyBlockerSummaries(statements, config, appId, environment, opt
3195
3419
  const descriptor = describePolicyStatement(statement);
3196
3420
  const review = statement?.meta?.review ?? {};
3197
3421
  const query = descriptor.codeAnchor || descriptor.label || statement?.when?.selector || '';
3198
- 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
+ );
3199
3430
  const blocker = {
3200
3431
  label: descriptor.label,
3201
3432
  codeAnchor: descriptor.codeAnchor,
@@ -3438,7 +3669,22 @@ function summarizeRawTextHints(policy, query) {
3438
3669
  }));
3439
3670
  }
3440
3671
 
3441
- 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
+ ) {
3442
3688
  const baseUrl = config.webBaseUrl || deriveWebBaseUrl(config.apiBaseUrl);
3443
3689
  if (!baseUrl) {
3444
3690
  return {
@@ -3451,6 +3697,9 @@ function buildPrivacyStudioUi(config, appId, environment, query, openRequested)
3451
3697
 
3452
3698
  const url = new URL('/admin/privacy', baseUrl);
3453
3699
  url.searchParams.set('service', appId);
3700
+ if (serviceName) {
3701
+ url.searchParams.set('serviceName', serviceName);
3702
+ }
3454
3703
  if (environment) {
3455
3704
  url.searchParams.set('env', environment);
3456
3705
  }