@corva/ui 3.80.0-4 → 3.80.0-5

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 (2) hide show
  1. package/mcp-server/server.mjs +161 -20
  2. package/package.json +1 -1
@@ -11,9 +11,10 @@ import { randomUUID } from 'crypto';
11
11
  import { defaultResource, resourceFromAttributes } from '@opentelemetry/resources';
12
12
  import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions';
13
13
  import { readFile } from 'fs/promises';
14
- import { join as join$1, resolve, dirname } from 'path';
15
- import { readFileSync } from 'fs';
14
+ import { join as join$1, dirname, resolve, sep } from 'path';
15
+ import { realpathSync, existsSync, statSync, readFileSync } from 'fs';
16
16
  import { fileURLToPath } from 'url';
17
+ import { homedir } from 'os';
17
18
  import { z } from 'zod';
18
19
 
19
20
  const LOG_SYMBOLS = {
@@ -246,12 +247,23 @@ const validateDsn = dsn => {
246
247
  return url.href;
247
248
  };
248
249
 
249
- const MCP_SERVER_VERSION = '1.9.1';
250
+ const MCP_SERVER_VERSION = '1.10.0';
250
251
 
251
- var version = "3.80.0-4";
252
+ var version = "3.80.0-5";
252
253
 
253
254
  const CORVA_UI_VERSION = version;
254
255
 
256
+ const getIdentityAttrs = identity => ({
257
+ ...(identity?.appKey ? {
258
+ 'corva.app.key': identity.appKey
259
+ } : {}),
260
+ ...(identity?.packageName ? {
261
+ 'corva.package.name': identity.packageName
262
+ } : {}),
263
+ ...(identity?.repoName ? {
264
+ 'corva.repo.name': identity.repoName
265
+ } : {})
266
+ });
255
267
  const createMetricsClient = meter => {
256
268
  const invocationCounter = meter.createCounter('mcp.server.tool.invocations', {
257
269
  description: 'Number of tool invocations'
@@ -344,24 +356,14 @@ const createMetricsClient = meter => {
344
356
  sessionCounter.add(1, {
345
357
  'mcp.client.name': clientName || UNKNOWN_CLIENT_ATTR,
346
358
  'mcp.client.version': clientVersion || UNKNOWN_CLIENT_ATTR,
347
- ...(projectIdentity?.appKey ? {
348
- 'corva.app.key': projectIdentity.appKey
349
- } : {}),
350
- ...(projectIdentity?.packageName ? {
351
- 'corva.package.name': projectIdentity.packageName
352
- } : {})
359
+ ...getIdentityAttrs(projectIdentity)
353
360
  });
354
361
  },
355
362
  recordSessionEnd: (durationMs, toolCount, clientName, clientVersion, projectIdentity) => {
356
363
  const attrs = {
357
364
  'mcp.client.name': clientName || UNKNOWN_CLIENT_ATTR,
358
365
  'mcp.client.version': clientVersion || UNKNOWN_CLIENT_ATTR,
359
- ...(projectIdentity?.appKey ? {
360
- 'corva.app.key': projectIdentity.appKey
361
- } : {}),
362
- ...(projectIdentity?.packageName ? {
363
- 'corva.package.name': projectIdentity.packageName
364
- } : {})
366
+ ...getIdentityAttrs(projectIdentity)
365
367
  };
366
368
  sessionDuration.record(durationMs, attrs);
367
369
  sessionToolCount.record(toolCount, attrs);
@@ -530,6 +532,9 @@ const recordInitSpan = (tracer, attrs) => {
530
532
  if (attrs.packageName) {
531
533
  span.setAttribute('corva.package.name', attrs.packageName);
532
534
  }
535
+ if (attrs.repoName) {
536
+ span.setAttribute('corva.repo.name', attrs.repoName);
537
+ }
533
538
  } finally {
534
539
  span.end();
535
540
  }
@@ -937,6 +942,131 @@ const extractContextFromMeta = meta => {
937
942
  return propagation.extract(context.active(), meta, metaGetter);
938
943
  };
939
944
 
945
+ const URL_WITH_SCHEME = /^[a-z][a-z0-9+.-]*:\/\//i;
946
+ // A drive letter and a colon is a local Windows path, as in git's `has_dos_drive_prefix`. Read as
947
+ // scp-like, `C:\Users\alice\repo` would export the path with the username.
948
+ const LOCAL_PREFIX = /^(?:file:|[a-z]:)/i;
949
+ // Git's scp-like form, `[user@]host:path`, where the host may be a bracketed IPv6 address. A slash
950
+ // before the first colon means a local path, so neither the user nor the host may contain one.
951
+ const SCP_LIKE = /^(?:[^@/:]+@)?(?:\[[^\]/]*\]|[^:/[\]]+):(.+)$/;
952
+
953
+ /**
954
+ * Returns the last path segment of a git remote URL without `.git`, or null for a local path
955
+ * (including a Windows drive path), a `file:` URL, a URL without a path and a URL it cannot parse.
956
+ * Only the repository name leaves the machine: never the host, owner, user or credentials.
957
+ */
958
+ const getRepoNameFromUrl = url => {
959
+ if (LOCAL_PREFIX.test(url)) {
960
+ return null;
961
+ }
962
+ let path;
963
+ if (URL_WITH_SCHEME.test(url)) {
964
+ try {
965
+ path = new URL(url).pathname;
966
+ } catch {
967
+ return null;
968
+ }
969
+ } else {
970
+ // The query goes first: an `@` or `:` inside it must not be read as the user or the host.
971
+ path = SCP_LIKE.exec(url.split(/[?#]/)[0])?.[1];
972
+ }
973
+ const name = path?.split('/').filter(Boolean).pop()?.replace(/\.git$/, '');
974
+ return name || null;
975
+ };
976
+
977
+ const SECTION_HEADER = /^\[\s*([^\s\]"]+)(?:\s+"((?:[^"\\]|\\.)*)")?\s*\]/;
978
+ const URL_ENTRY = /^url\s*=\s*(.*)$/i;
979
+
980
+ // A `.git` in the home directory or above it belongs to a dotfiles or system repository, not to
981
+ // the project. Real paths, so a symbolic link cannot walk past the stop.
982
+ const findGitEntry = (startDir, homeDir) => {
983
+ const home = realpathSync(homeDir);
984
+ const isHomeOrAbove = dir => dir === home || home.startsWith(`${dir}${sep}`);
985
+ for (let dir = realpathSync(startDir); !isHomeOrAbove(dir) && dirname(dir) !== dir; dir = dirname(dir)) {
986
+ const entry = join$1(dir, '.git');
987
+ if (existsSync(entry)) {
988
+ return entry;
989
+ }
990
+ }
991
+ return null;
992
+ };
993
+
994
+ // A linked worktree or a submodule has a `.git` file that points to its git directory; a worktree's
995
+ // git directory points on to the shared one through `commondir`, and that is where `origin` lives.
996
+ const getConfigPath = gitEntry => {
997
+ if (statSync(gitEntry).isDirectory()) {
998
+ return join$1(gitEntry, 'config');
999
+ }
1000
+ const gitDirPath = /^gitdir:\s*(.+)$/m.exec(readFileSync(gitEntry, 'utf-8'))?.[1].trim();
1001
+ if (!gitDirPath) {
1002
+ return null;
1003
+ }
1004
+ const gitDir = resolve(dirname(gitEntry), gitDirPath);
1005
+ const commonDirFile = join$1(gitDir, 'commondir');
1006
+ const commonDir = existsSync(commonDirFile) ? resolve(gitDir, readFileSync(commonDirFile, 'utf-8').trim()) : gitDir;
1007
+ return join$1(commonDir, 'config');
1008
+ };
1009
+
1010
+ // A git config value can mix quoted and unquoted parts, and an unquoted `#` or `;` starts a comment
1011
+ // anywhere in it. Reading only a leading quoted part, or only a comment after whitespace, would
1012
+ // return the owner or a commented-out URL.
1013
+ const parseValue = raw => {
1014
+ let value = '';
1015
+ let quoted = false;
1016
+ for (let i = 0; i < raw.length; i += 1) {
1017
+ const char = raw[i];
1018
+ if (char === '\\') {
1019
+ value += raw[i + 1] ?? '';
1020
+ i += 1;
1021
+ } else if (char === '"') {
1022
+ quoted = !quoted;
1023
+ } else if (!quoted && (char === '#' || char === ';')) {
1024
+ break;
1025
+ } else {
1026
+ value += char;
1027
+ }
1028
+ }
1029
+ return value.trim();
1030
+ };
1031
+
1032
+ // `include`/`includeIf` are not followed: they can point anywhere on the machine. The first `url`
1033
+ // wins because that is the one `git fetch` uses.
1034
+ const readOriginUrl = configPath => {
1035
+ let inOrigin = false;
1036
+ for (const rawLine of readFileSync(configPath, 'utf-8').split(/\r?\n/)) {
1037
+ let line = rawLine.trim();
1038
+ const section = SECTION_HEADER.exec(line);
1039
+
1040
+ // Git also accepts an entry on the header line: `[remote "origin"] url = ...`.
1041
+ if (section) {
1042
+ inOrigin = section[1].toLowerCase() === 'remote' && section[2] === 'origin';
1043
+ line = line.slice(section[0].length).trim();
1044
+ }
1045
+ const value = inOrigin ? URL_ENTRY.exec(line)?.[1] : undefined;
1046
+ if (value !== undefined) {
1047
+ return parseValue(value);
1048
+ }
1049
+ }
1050
+ return null;
1051
+ };
1052
+
1053
+ /**
1054
+ * Returns the repository name of the `origin` remote of the nearest git repository above
1055
+ * `startDir`, or null. Reads the git files directly instead of spawning `git`, which may be missing
1056
+ * and would cost a process on every session start. Never throws.
1057
+ */
1058
+ const resolveRepoName = (startDir, homeDir) => {
1059
+ try {
1060
+ // `homedir()` throws when HOME is unset and the user has no passwd entry, as in some containers.
1061
+ const gitEntry = findGitEntry(startDir, homeDir ?? homedir());
1062
+ const configPath = gitEntry && getConfigPath(gitEntry);
1063
+ const url = configPath && readOriginUrl(configPath);
1064
+ return url ? getRepoNameFromUrl(url) : null;
1065
+ } catch {
1066
+ return null;
1067
+ }
1068
+ };
1069
+
940
1070
  const readJsonField = (filePath, ...keys) => {
941
1071
  try {
942
1072
  let value = JSON.parse(readFileSync(filePath, 'utf-8'));
@@ -974,12 +1104,16 @@ const getProjectIdentityAttrs = identity => {
974
1104
  if (identity?.packageName) {
975
1105
  attrs['corva.package.name'] = identity.packageName;
976
1106
  }
1107
+ if (identity?.repoName) {
1108
+ attrs['corva.repo.name'] = identity.repoName;
1109
+ }
977
1110
  return attrs;
978
1111
  };
979
1112
  const resolveProjectIdentity = async (server, logger) => {
980
1113
  const empty = {
981
1114
  appKey: null,
982
- packageName: null
1115
+ packageName: null,
1116
+ repoName: null
983
1117
  };
984
1118
  try {
985
1119
  const capabilities = server.getClientCapabilities();
@@ -995,15 +1129,20 @@ const resolveProjectIdentity = async (server, logger) => {
995
1129
  const rootPath = fileURLToPath(roots[0].uri);
996
1130
  const appKey = findUpJsonField(rootPath, 'manifest.json', 'application', 'key');
997
1131
  const packageName = findUpJsonField(rootPath, 'package.json', 'name');
1132
+ const repoName = resolveRepoName(rootPath);
998
1133
  if (appKey) {
999
1134
  logger.info(`Consuming app: ${appKey}`);
1000
1135
  }
1001
1136
  if (packageName) {
1002
1137
  logger.info(`Consuming package: ${packageName}`);
1003
1138
  }
1139
+ if (repoName) {
1140
+ logger.info(`Consuming repository: ${repoName}`);
1141
+ }
1004
1142
  return {
1005
1143
  appKey,
1006
- packageName
1144
+ packageName,
1145
+ repoName
1007
1146
  };
1008
1147
  } catch {
1009
1148
  return empty;
@@ -1077,7 +1216,8 @@ const recordSessionStart = async ({
1077
1216
  protocolVersion: MCP_PROTOCOL_VERSION,
1078
1217
  networkTransport: MCP_NETWORK_TRANSPORT,
1079
1218
  appKey: projectIdentity?.appKey ?? undefined,
1080
- packageName: projectIdentity?.packageName ?? undefined
1219
+ packageName: projectIdentity?.packageName ?? undefined,
1220
+ repoName: projectIdentity?.repoName ?? undefined
1081
1221
  });
1082
1222
  };
1083
1223
 
@@ -22670,7 +22810,7 @@ const diagnosticsToolName = 'get_diagnostics';
22670
22810
  const diagnosticsToolTitle = 'Get Server Diagnostics';
22671
22811
  const diagnosticsToolDescription = `Get MCP server health metrics.
22672
22812
  Returns server version, uptime, memory usage, request statistics, telemetry status,
22673
- whether feedback can reach the maintainers, project identity (app key and package name), and the prompts this server registers.`;
22813
+ whether feedback can reach the maintainers, project identity (app key, package name and repository name), and the prompts this server registers.`;
22674
22814
  const diagnosticsToolSchema = {};
22675
22815
  const formatUptime = ms => {
22676
22816
  const seconds = Math.floor(ms / 1000);
@@ -22738,6 +22878,7 @@ const handleGetDiagnostics = (stats, telemetry, identity, registeredPromptNames)
22738
22878
  text += h2('Project Identity');
22739
22879
  text += `${bold('App key')}: ${identity?.appKey ?? 'N/A'}\n`;
22740
22880
  text += `${bold('Package name')}: ${identity?.packageName ?? 'N/A'}\n`;
22881
+ text += `${bold('Repository')}: ${identity?.repoName ?? 'N/A'}\n`;
22741
22882
  if (registeredPromptNames?.length) {
22742
22883
  text += `\n${h2('Registered Prompts')}`;
22743
22884
  text += `${list(registeredPromptNames.map(name => `\`${name}\``))}\n\n`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@corva/ui",
3
- "version": "3.80.0-4",
3
+ "version": "3.80.0-5",
4
4
  "license": "UNLICENSED",
5
5
  "description": "Shared components/utils for Corva ui projects",
6
6
  "repository": {