mcp-perforce-server 2.1.2 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -77,7 +77,15 @@ const TOOL_HANDLERS = {
77
77
  'p4.shelve': tools.p4Shelve,
78
78
  'p4.unshelve': tools.p4Unshelve,
79
79
  'p4.changes': tools.p4Changes,
80
+ 'p4.review': tools.p4Review,
81
+ 'p4.reviews': tools.p4Reviews,
82
+ 'p4.interchanges': tools.p4Interchanges,
83
+ 'p4.integrated': tools.p4Integrated,
84
+ 'p4.review.bundle': tools.p4ReviewBundle,
85
+ 'p4.change.inspect': tools.p4ChangeInspect,
86
+ 'p4.path.synccheck': tools.p4PathSyncCheck,
80
87
  'p4.blame': tools.p4Blame,
88
+ 'p4.annotate': tools.p4Annotate,
81
89
  'p4.copy': tools.p4Copy,
82
90
  'p4.move': tools.p4Move,
83
91
  'p4.integrate': tools.p4Integrate,
@@ -122,6 +130,33 @@ const WRITE_TOOLS = new Set([
122
130
  'p4.merge',
123
131
  ]);
124
132
  const CACHEABLE_TOOLS = new Set(Object.keys(TOOL_HANDLERS).filter((toolName) => !WRITE_TOOLS.has(toolName)));
133
+ const LOW_LATENCY_CACHE_TTL_TOOLS = new Set([
134
+ 'p4.status',
135
+ 'p4.opened',
136
+ 'p4.changes',
137
+ 'p4.review',
138
+ 'p4.reviews',
139
+ 'p4.interchanges',
140
+ 'p4.integrated',
141
+ 'p4.review.bundle',
142
+ 'p4.change.inspect',
143
+ 'p4.path.synccheck',
144
+ ]);
145
+ const STABLE_CACHE_TTL_TOOLS = new Set([
146
+ 'p4.info',
147
+ 'p4.users',
148
+ 'p4.user',
149
+ 'p4.clients',
150
+ 'p4.client',
151
+ 'p4.labels',
152
+ 'p4.label',
153
+ 'p4.streams',
154
+ 'p4.stream',
155
+ 'p4.jobs',
156
+ 'p4.job',
157
+ 'p4.config.detect',
158
+ 'p4.compliance',
159
+ ]);
125
160
  function getPerformanceMode() {
126
161
  const mode = (process.env.P4_PERFORMANCE_MODE || 'fast').toLowerCase();
127
162
  if (mode === 'balanced' || mode === 'secure') {
@@ -155,14 +190,52 @@ function getEnvInt(name, fallback) {
155
190
  const parsed = parseInt(process.env[name] || '', 10);
156
191
  return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
157
192
  }
193
+ function parseToolCacheTtlOverrides(raw) {
194
+ const overrides = new Map();
195
+ if (!raw) {
196
+ return overrides;
197
+ }
198
+ for (const segment of raw.split(',')) {
199
+ const trimmed = segment.trim();
200
+ if (!trimmed) {
201
+ continue;
202
+ }
203
+ const splitIndex = trimmed.includes('=') ? trimmed.indexOf('=') : trimmed.indexOf(':');
204
+ if (splitIndex <= 0) {
205
+ continue;
206
+ }
207
+ const key = trimmed.slice(0, splitIndex).trim();
208
+ const valueRaw = trimmed.slice(splitIndex + 1).trim();
209
+ const value = parseInt(valueRaw, 10);
210
+ if (key && Number.isFinite(value) && value >= 0) {
211
+ overrides.set(key, value);
212
+ }
213
+ }
214
+ return overrides;
215
+ }
158
216
  class MCPPerforceServer {
159
217
  constructor() {
160
218
  this.prettyJson = process.env.P4_PRETTY_JSON === 'true';
161
219
  this.responseCacheEnabled = process.env.P4_RESPONSE_CACHE !== 'false';
162
220
  this.responseCacheTtlMs = getEnvInt('P4_RESPONSE_CACHE_TTL_MS', getDefaultResponseCacheTtlMs());
163
221
  this.responseCacheMaxEntries = getEnvInt('P4_RESPONSE_CACHE_MAX_ENTRIES', getDefaultResponseCacheMaxEntries());
222
+ this.responseCacheTtlOverrides = parseToolCacheTtlOverrides(process.env.P4_RESPONSE_CACHE_TTL_MAP);
223
+ this.negativeCacheEnabled = process.env.P4_NEGATIVE_CACHE !== 'false';
224
+ this.negativeCacheTtlMs = getEnvInt('P4_NEGATIVE_CACHE_TTL_MS', Math.max(1000, Math.min(this.responseCacheTtlMs, 5000)));
225
+ this.negativeCacheableErrorCodes = new Set([
226
+ 'P4_INVALID_ARGS',
227
+ 'P4_READONLY_MODE',
228
+ 'P4_DELETE_DISABLED',
229
+ 'P4_AUDIT_DISABLED',
230
+ 'P4_CONFIG_NOT_FOUND',
231
+ ]);
164
232
  this.responseCache = new Map();
165
233
  this.inFlightReadRequests = new Map();
234
+ this.toolPerformance = new Map();
235
+ this.perfMetricsSampleSize = getEnvInt('P4_PERF_METRICS_SAMPLE_SIZE', 200) || 200;
236
+ this.perfMetricsEnabled = process.env.P4_LOG_PERF_METRICS === 'true';
237
+ this.perfMetricsIntervalMs = getEnvInt('P4_LOG_PERF_METRICS_INTERVAL_MS', 60000) || 60000;
238
+ this.perfMetricsTimer = null;
166
239
  this.cacheEpoch = 0;
167
240
  this.server = new index_js_1.Server({
168
241
  name: 'mcp-perforce-server',
@@ -180,6 +253,7 @@ class MCPPerforceServer {
180
253
  };
181
254
  this.setupToolHandlers();
182
255
  this.setupErrorHandling();
256
+ this.setupPerformanceLogging();
183
257
  }
184
258
  setupErrorHandling() {
185
259
  this.server.onerror = (error) => {
@@ -187,15 +261,35 @@ class MCPPerforceServer {
187
261
  };
188
262
  process.on('SIGINT', async () => {
189
263
  log.info('Shutting down MCP Perforce server...');
264
+ this.stopPerformanceLogging();
190
265
  await this.server.close();
191
266
  process.exit(0);
192
267
  });
193
268
  process.on('SIGTERM', async () => {
194
269
  log.info('Shutting down MCP Perforce server...');
270
+ this.stopPerformanceLogging();
195
271
  await this.server.close();
196
272
  process.exit(0);
197
273
  });
198
274
  }
275
+ setupPerformanceLogging() {
276
+ if (!this.perfMetricsEnabled || this.perfMetricsIntervalMs <= 0) {
277
+ return;
278
+ }
279
+ this.perfMetricsTimer = setInterval(() => {
280
+ this.logPerformanceSnapshot();
281
+ }, this.perfMetricsIntervalMs);
282
+ if (typeof this.perfMetricsTimer.unref === 'function') {
283
+ this.perfMetricsTimer.unref();
284
+ }
285
+ }
286
+ stopPerformanceLogging() {
287
+ if (!this.perfMetricsTimer) {
288
+ return;
289
+ }
290
+ clearInterval(this.perfMetricsTimer);
291
+ this.perfMetricsTimer = null;
292
+ }
199
293
  serializeResult(value) {
200
294
  if (this.prettyJson) {
201
295
  return JSON.stringify(value, null, 2);
@@ -207,6 +301,19 @@ class MCPPerforceServer {
207
301
  content: [{ type: 'text', text: this.serializeResult(value) }],
208
302
  };
209
303
  }
304
+ getToolCacheTtlMs(toolName) {
305
+ const override = this.responseCacheTtlOverrides.get(toolName);
306
+ if (override !== undefined) {
307
+ return override;
308
+ }
309
+ if (LOW_LATENCY_CACHE_TTL_TOOLS.has(toolName)) {
310
+ return Math.max(1000, Math.floor(this.responseCacheTtlMs / 2));
311
+ }
312
+ if (STABLE_CACHE_TTL_TOOLS.has(toolName)) {
313
+ return Math.max(this.responseCacheTtlMs, 15000);
314
+ }
315
+ return this.responseCacheTtlMs;
316
+ }
210
317
  getCachedResult(cacheKey) {
211
318
  const entry = this.responseCache.get(cacheKey);
212
319
  if (!entry) {
@@ -216,21 +323,33 @@ class MCPPerforceServer {
216
323
  this.responseCache.delete(cacheKey);
217
324
  return undefined;
218
325
  }
219
- return entry.value;
326
+ // LRU behavior: refresh recency on read hits.
327
+ this.responseCache.delete(cacheKey);
328
+ this.responseCache.set(cacheKey, entry);
329
+ return {
330
+ result: entry.value,
331
+ cacheStatus: entry.isNegative ? 'negative_hit' : 'hit',
332
+ };
220
333
  }
221
- setCachedResult(cacheKey, value) {
222
- if (this.responseCacheMaxEntries <= 0 || this.responseCacheTtlMs <= 0) {
334
+ setCachedResult(cacheKey, value, ttlMs, isNegative = false) {
335
+ if (this.responseCacheMaxEntries <= 0 || ttlMs <= 0) {
223
336
  return;
224
337
  }
225
- if (this.responseCache.size >= this.responseCacheMaxEntries) {
338
+ if (this.responseCache.has(cacheKey)) {
339
+ this.responseCache.delete(cacheKey);
340
+ }
341
+ while (this.responseCache.size >= this.responseCacheMaxEntries) {
226
342
  const oldestKey = this.responseCache.keys().next().value;
227
- if (oldestKey) {
228
- this.responseCache.delete(oldestKey);
343
+ if (!oldestKey) {
344
+ break;
229
345
  }
346
+ this.responseCache.delete(oldestKey);
230
347
  }
231
348
  this.responseCache.set(cacheKey, {
232
349
  value,
233
- expiresAt: Date.now() + this.responseCacheTtlMs,
350
+ expiresAt: Date.now() + ttlMs,
351
+ isNegative,
352
+ ttlMs,
234
353
  });
235
354
  }
236
355
  clearReadCache() {
@@ -242,6 +361,115 @@ class MCPPerforceServer {
242
361
  buildCacheKey(name, args) {
243
362
  return `${name}:${JSON.stringify(args ?? {})}`;
244
363
  }
364
+ shouldCacheNegativeResult(result) {
365
+ if (!this.negativeCacheEnabled || !result || typeof result !== 'object') {
366
+ return false;
367
+ }
368
+ const resultRecord = result;
369
+ if (resultRecord.ok !== false) {
370
+ return false;
371
+ }
372
+ const errorCode = resultRecord.error?.code;
373
+ return typeof errorCode === 'string' && this.negativeCacheableErrorCodes.has(errorCode);
374
+ }
375
+ recordToolPerformance(toolName, durationMs, result, cacheStatus, subcallCounts) {
376
+ let stats = this.toolPerformance.get(toolName);
377
+ if (!stats) {
378
+ stats = {
379
+ calls: 0,
380
+ successes: 0,
381
+ errors: 0,
382
+ cacheHits: 0,
383
+ negativeCacheHits: 0,
384
+ cacheMisses: 0,
385
+ inFlightHits: 0,
386
+ totalDurationMs: 0,
387
+ durationsMs: [],
388
+ subcallTotals: {},
389
+ };
390
+ this.toolPerformance.set(toolName, stats);
391
+ }
392
+ stats.calls += 1;
393
+ stats.totalDurationMs += durationMs;
394
+ stats.durationsMs.push(durationMs);
395
+ if (stats.durationsMs.length > this.perfMetricsSampleSize) {
396
+ stats.durationsMs.shift();
397
+ }
398
+ const ok = !!(result && typeof result === 'object' && result.ok === true);
399
+ if (ok) {
400
+ stats.successes += 1;
401
+ }
402
+ else {
403
+ stats.errors += 1;
404
+ }
405
+ switch (cacheStatus) {
406
+ case 'hit':
407
+ stats.cacheHits += 1;
408
+ break;
409
+ case 'negative_hit':
410
+ stats.negativeCacheHits += 1;
411
+ break;
412
+ case 'in_flight':
413
+ stats.inFlightHits += 1;
414
+ break;
415
+ case 'miss':
416
+ stats.cacheMisses += 1;
417
+ break;
418
+ default:
419
+ break;
420
+ }
421
+ if (subcallCounts) {
422
+ for (const [name, value] of Object.entries(subcallCounts)) {
423
+ if (!Number.isFinite(value) || value <= 0) {
424
+ continue;
425
+ }
426
+ stats.subcallTotals[name] = (stats.subcallTotals[name] || 0) + value;
427
+ }
428
+ }
429
+ }
430
+ getPercentile(samples, percentile) {
431
+ if (samples.length === 0) {
432
+ return 0;
433
+ }
434
+ const sorted = [...samples].sort((a, b) => a - b);
435
+ const index = Math.min(sorted.length - 1, Math.max(0, Math.floor((percentile / 100) * (sorted.length - 1))));
436
+ return Math.round(sorted[index]);
437
+ }
438
+ extractSubcallCounts(result) {
439
+ if (!result || typeof result !== 'object') {
440
+ return undefined;
441
+ }
442
+ const outer = result;
443
+ if (!outer.result || typeof outer.result !== 'object') {
444
+ return undefined;
445
+ }
446
+ const meta = outer.result.meta;
447
+ if (!meta || !meta.subcallCounts) {
448
+ return undefined;
449
+ }
450
+ return meta.subcallCounts;
451
+ }
452
+ logPerformanceSnapshot() {
453
+ if (this.toolPerformance.size === 0) {
454
+ return;
455
+ }
456
+ const byCalls = [...this.toolPerformance.entries()].sort((a, b) => b[1].calls - a[1].calls);
457
+ const topEntries = byCalls.slice(0, 12).map(([toolName, stats]) => {
458
+ const cacheHitsTotal = stats.cacheHits + stats.negativeCacheHits + stats.inFlightHits;
459
+ const cacheHitRate = stats.calls > 0 ? Math.round((cacheHitsTotal / stats.calls) * 100) : 0;
460
+ const avgMs = stats.calls > 0 ? Math.round(stats.totalDurationMs / stats.calls) : 0;
461
+ return {
462
+ tool: toolName,
463
+ calls: stats.calls,
464
+ avgMs,
465
+ p50Ms: this.getPercentile(stats.durationsMs, 50),
466
+ p95Ms: this.getPercentile(stats.durationsMs, 95),
467
+ cacheHitRate,
468
+ subcalls: stats.subcallTotals,
469
+ };
470
+ });
471
+ log.info('Performance snapshot:', topEntries);
472
+ }
245
473
  async executeTool(name, args) {
246
474
  const handler = TOOL_HANDLERS[name];
247
475
  if (!handler) {
@@ -251,29 +479,33 @@ class MCPPerforceServer {
251
479
  }
252
480
  async executeToolWithCaching(name, args) {
253
481
  if (!this.responseCacheEnabled || !CACHEABLE_TOOLS.has(name)) {
254
- return this.executeTool(name, args);
482
+ return { result: await this.executeTool(name, args), cacheStatus: 'uncacheable' };
255
483
  }
256
484
  const cacheKey = this.buildCacheKey(name, args);
257
485
  const cachedResult = this.getCachedResult(cacheKey);
258
- if (cachedResult !== undefined) {
486
+ if (cachedResult) {
259
487
  return cachedResult;
260
488
  }
261
489
  const inFlight = this.inFlightReadRequests.get(cacheKey);
262
490
  if (inFlight) {
263
- return inFlight;
491
+ return { result: await inFlight, cacheStatus: 'in_flight' };
264
492
  }
265
493
  const pending = this.executeTool(name, args);
266
494
  this.inFlightReadRequests.set(cacheKey, pending);
267
495
  const startedEpoch = this.cacheEpoch;
268
496
  try {
269
497
  const result = await pending;
270
- if (startedEpoch === this.cacheEpoch &&
271
- result &&
272
- typeof result === 'object' &&
273
- result.ok === true) {
274
- this.setCachedResult(cacheKey, result);
498
+ if (startedEpoch === this.cacheEpoch) {
499
+ const toolTtlMs = this.getToolCacheTtlMs(name);
500
+ if (result && typeof result === 'object' && result.ok === true) {
501
+ this.setCachedResult(cacheKey, result, toolTtlMs, false);
502
+ }
503
+ else if (this.shouldCacheNegativeResult(result)) {
504
+ const negativeTtl = Math.min(toolTtlMs, this.negativeCacheTtlMs);
505
+ this.setCachedResult(cacheKey, result, negativeTtl, true);
506
+ }
275
507
  }
276
- return result;
508
+ return { result, cacheStatus: 'miss' };
277
509
  }
278
510
  finally {
279
511
  this.inFlightReadRequests.delete(cacheKey);
@@ -601,7 +833,7 @@ class MCPPerforceServer {
601
833
  },
602
834
  {
603
835
  name: 'p4.describe',
604
- description: 'Describe a changelist with metadata and affected files (equivalent to p4 describe -s)',
836
+ description: 'Describe a changelist with metadata, affected files, and optional diff content',
605
837
  inputSchema: {
606
838
  type: 'object',
607
839
  properties: {
@@ -612,6 +844,15 @@ class MCPPerforceServer {
612
844
  ],
613
845
  description: 'Changelist number (required)',
614
846
  },
847
+ includeDiff: {
848
+ type: 'boolean',
849
+ description: 'Include patch/diff content from p4 describe -d* (optional, default false)',
850
+ },
851
+ diffFormat: {
852
+ type: 'string',
853
+ enum: ['u', 'c', 'n', 's'],
854
+ description: 'Diff format when includeDiff=true: u=unified, c=context, n=RCS, s=summary',
855
+ },
615
856
  workspacePath: {
616
857
  type: 'string',
617
858
  description: 'Path to workspace directory (optional, defaults to current directory)',
@@ -800,6 +1041,214 @@ class MCPPerforceServer {
800
1041
  additionalProperties: false,
801
1042
  },
802
1043
  },
1044
+ {
1045
+ name: 'p4.review',
1046
+ description: 'List changelists pending review',
1047
+ inputSchema: {
1048
+ type: 'object',
1049
+ properties: {
1050
+ counter: {
1051
+ type: 'string',
1052
+ description: 'Review counter/token used by p4 review -t (optional)',
1053
+ },
1054
+ filespec: {
1055
+ type: 'string',
1056
+ description: 'Optional filespec to filter changelists',
1057
+ },
1058
+ workspacePath: {
1059
+ type: 'string',
1060
+ description: 'Path to workspace directory (optional, defaults to current directory)',
1061
+ },
1062
+ },
1063
+ additionalProperties: false,
1064
+ },
1065
+ },
1066
+ {
1067
+ name: 'p4.reviews',
1068
+ description: 'List reviewers for files or a changelist',
1069
+ inputSchema: {
1070
+ type: 'object',
1071
+ properties: {
1072
+ changelist: {
1073
+ type: 'string',
1074
+ description: 'Optional changelist number to resolve reviewers for',
1075
+ },
1076
+ files: {
1077
+ type: 'array',
1078
+ items: { type: 'string' },
1079
+ description: 'Optional file list/filespecs to resolve reviewers for',
1080
+ },
1081
+ workspacePath: {
1082
+ type: 'string',
1083
+ description: 'Path to workspace directory (optional, defaults to current directory)',
1084
+ },
1085
+ },
1086
+ additionalProperties: false,
1087
+ },
1088
+ },
1089
+ {
1090
+ name: 'p4.interchanges',
1091
+ description: 'List changelists not yet integrated between two paths',
1092
+ inputSchema: {
1093
+ type: 'object',
1094
+ properties: {
1095
+ sourcePath: {
1096
+ type: 'string',
1097
+ description: 'Source depot filespec/path (required)',
1098
+ },
1099
+ targetPath: {
1100
+ type: 'string',
1101
+ description: 'Target depot filespec/path (required)',
1102
+ },
1103
+ max: {
1104
+ type: 'number',
1105
+ description: 'Maximum number of changelists to return (optional)',
1106
+ },
1107
+ longDescription: {
1108
+ type: 'boolean',
1109
+ description: 'Include long descriptions (optional, equivalent to -l)',
1110
+ },
1111
+ workspacePath: {
1112
+ type: 'string',
1113
+ description: 'Path to workspace directory (optional, defaults to current directory)',
1114
+ },
1115
+ },
1116
+ required: ['sourcePath', 'targetPath'],
1117
+ additionalProperties: false,
1118
+ },
1119
+ },
1120
+ {
1121
+ name: 'p4.integrated',
1122
+ description: 'Show integration history for source/target paths',
1123
+ inputSchema: {
1124
+ type: 'object',
1125
+ properties: {
1126
+ sourcePath: {
1127
+ type: 'string',
1128
+ description: 'Source depot filespec/path (required)',
1129
+ },
1130
+ targetPath: {
1131
+ type: 'string',
1132
+ description: 'Optional target depot filespec/path',
1133
+ },
1134
+ workspacePath: {
1135
+ type: 'string',
1136
+ description: 'Path to workspace directory (optional, defaults to current directory)',
1137
+ },
1138
+ },
1139
+ required: ['sourcePath'],
1140
+ additionalProperties: false,
1141
+ },
1142
+ },
1143
+ {
1144
+ name: 'p4.review.bundle',
1145
+ description: 'Composite review workflow: pending changes with optional details/reviewers',
1146
+ inputSchema: {
1147
+ type: 'object',
1148
+ properties: {
1149
+ counter: {
1150
+ type: 'string',
1151
+ description: 'Optional review counter/token for p4 review -t',
1152
+ },
1153
+ filespec: {
1154
+ type: 'string',
1155
+ description: 'Optional filespec filter',
1156
+ },
1157
+ maxChanges: {
1158
+ type: 'number',
1159
+ description: 'Maximum changelists to include (optional, default 10)',
1160
+ },
1161
+ includeDescribe: {
1162
+ type: 'boolean',
1163
+ description: 'Include p4 describe data per changelist (optional, default true)',
1164
+ },
1165
+ includeReviewers: {
1166
+ type: 'boolean',
1167
+ description: 'Include p4 reviews per changelist (optional, default true)',
1168
+ },
1169
+ workspacePath: {
1170
+ type: 'string',
1171
+ description: 'Path to workspace directory (optional, defaults to current directory)',
1172
+ },
1173
+ },
1174
+ additionalProperties: false,
1175
+ },
1176
+ },
1177
+ {
1178
+ name: 'p4.change.inspect',
1179
+ description: 'Composite changelist inspection: describe + fixes + reviewers (+ optional file history)',
1180
+ inputSchema: {
1181
+ type: 'object',
1182
+ properties: {
1183
+ changelist: {
1184
+ type: 'string',
1185
+ description: 'Changelist number to inspect (required)',
1186
+ },
1187
+ includeDiff: {
1188
+ type: 'boolean',
1189
+ description: 'Include describe diff content in inspection output (optional, default false)',
1190
+ },
1191
+ diffFormat: {
1192
+ type: 'string',
1193
+ enum: ['u', 'c', 'n', 's'],
1194
+ description: 'Diff format for describe when includeDiff=true',
1195
+ },
1196
+ includeFileHistory: {
1197
+ type: 'boolean',
1198
+ description: 'Include p4 filelog for affected files (optional, default false)',
1199
+ },
1200
+ maxFilesWithHistory: {
1201
+ type: 'number',
1202
+ description: 'Maximum files to fetch filelog for (optional, default 5)',
1203
+ },
1204
+ maxRevisions: {
1205
+ type: 'number',
1206
+ description: 'Maximum revisions per filelog call (optional, default 5)',
1207
+ },
1208
+ workspacePath: {
1209
+ type: 'string',
1210
+ description: 'Path to workspace directory (optional, defaults to current directory)',
1211
+ },
1212
+ },
1213
+ required: ['changelist'],
1214
+ additionalProperties: false,
1215
+ },
1216
+ },
1217
+ {
1218
+ name: 'p4.path.synccheck',
1219
+ description: 'Composite path sync analysis using interchanges/integrated in one call',
1220
+ inputSchema: {
1221
+ type: 'object',
1222
+ properties: {
1223
+ sourcePath: {
1224
+ type: 'string',
1225
+ description: 'Source depot filespec/path (required)',
1226
+ },
1227
+ targetPath: {
1228
+ type: 'string',
1229
+ description: 'Target depot filespec/path (required)',
1230
+ },
1231
+ maxInterchanges: {
1232
+ type: 'number',
1233
+ description: 'Maximum interchanges per direction (optional, default 50)',
1234
+ },
1235
+ includeIntegrated: {
1236
+ type: 'boolean',
1237
+ description: 'Include p4 integrated history (optional, default true)',
1238
+ },
1239
+ checkBothDirections: {
1240
+ type: 'boolean',
1241
+ description: 'Run reverse-direction comparison too (optional, default true)',
1242
+ },
1243
+ workspacePath: {
1244
+ type: 'string',
1245
+ description: 'Path to workspace directory (optional, defaults to current directory)',
1246
+ },
1247
+ },
1248
+ required: ['sourcePath', 'targetPath'],
1249
+ additionalProperties: false,
1250
+ },
1251
+ },
803
1252
  {
804
1253
  name: 'p4.blame',
805
1254
  description: 'Show file annotations with change history (like git blame)',
@@ -819,6 +1268,25 @@ class MCPPerforceServer {
819
1268
  additionalProperties: false,
820
1269
  },
821
1270
  },
1271
+ {
1272
+ name: 'p4.annotate',
1273
+ description: 'Alias of p4.blame (line-by-line annotation)',
1274
+ inputSchema: {
1275
+ type: 'object',
1276
+ properties: {
1277
+ file: {
1278
+ type: 'string',
1279
+ description: 'File to annotate (required)',
1280
+ },
1281
+ workspacePath: {
1282
+ type: 'string',
1283
+ description: 'Path to workspace directory (optional, defaults to current directory)',
1284
+ },
1285
+ },
1286
+ required: ['file'],
1287
+ additionalProperties: false,
1288
+ },
1289
+ },
822
1290
  // Medium Priority Tools
823
1291
  {
824
1292
  name: 'p4.copy',
@@ -1392,10 +1860,12 @@ class MCPPerforceServer {
1392
1860
  }
1393
1861
  }
1394
1862
  const toolArgs = (args || {});
1395
- const result = await this.executeToolWithCaching(name, toolArgs);
1863
+ const execution = await this.executeToolWithCaching(name, toolArgs);
1864
+ const result = execution.result;
1396
1865
  if (WRITE_TOOLS.has(name) && result && typeof result === 'object' && result.ok) {
1397
1866
  this.clearReadCache();
1398
1867
  }
1868
+ this.recordToolPerformance(name, Date.now() - startTime, result, execution.cacheStatus, this.extractSubcallCounts(result));
1399
1869
  // Audit log successful operation
1400
1870
  this.context.security.logAuditEntry({
1401
1871
  tool: name,
@@ -1411,6 +1881,7 @@ class MCPPerforceServer {
1411
1881
  catch (error) {
1412
1882
  const duration = Date.now() - startTime;
1413
1883
  const errorCode = error instanceof types_js_1.McpError ? error.code : 'INTERNAL_ERROR';
1884
+ this.recordToolPerformance(name, duration, { ok: false, error: { code: String(errorCode) } }, 'uncacheable');
1414
1885
  log.error('Tool execution error:', error);
1415
1886
  // Audit log failed operation
1416
1887
  this.context.security.logAuditEntry({
@@ -1470,7 +1941,14 @@ Environment Variables:
1470
1941
  P4_PRETTY_JSON=true Pretty-print JSON responses (default: compact JSON)
1471
1942
  P4_RESPONSE_CACHE=false Disable read-result cache (default: enabled)
1472
1943
  P4_RESPONSE_CACHE_TTL_MS=5000 Cache TTL in ms (default by mode: fast 5000, balanced 3000, secure 1000)
1944
+ P4_RESPONSE_CACHE_TTL_MAP='p4.info=30000,p4.review=2000' Per-tool TTL overrides
1473
1945
  P4_RESPONSE_CACHE_MAX_ENTRIES=400 Max cached responses (default by mode)
1946
+ P4_NEGATIVE_CACHE=false Disable short-lived caching of predictable read errors
1947
+ P4_NEGATIVE_CACHE_TTL_MS=5000 Negative-cache TTL in ms
1948
+ P4_WORKFLOW_CONCURRENCY=6 Max concurrent subcalls in composite workflow tools
1949
+ P4_LOG_PERF_METRICS=true Enable periodic performance snapshot logs
1950
+ P4_LOG_PERF_METRICS_INTERVAL_MS=60000 Performance snapshot interval in ms
1951
+ P4_PERF_METRICS_SAMPLE_SIZE=200 Duration sample size per tool for p50/p95
1474
1952
 
1475
1953
  Compliance & Security:
1476
1954
  P4_ENABLE_AUDIT_LOGGING=true|false Override audit logging (default in fast mode: false)