@mgsoftwarebv/mcp-server-bridge 3.4.1 → 3.5.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/index.js CHANGED
@@ -6170,20 +6170,20 @@ var require_resolve = __commonJS({
6170
6170
  return false;
6171
6171
  }
6172
6172
  function countKeys(schema) {
6173
- let count = 0;
6173
+ let count2 = 0;
6174
6174
  for (const key in schema) {
6175
6175
  if (key === "$ref")
6176
6176
  return Infinity;
6177
- count++;
6177
+ count2++;
6178
6178
  if (SIMPLE_INLINED.has(key))
6179
6179
  continue;
6180
6180
  if (typeof schema[key] == "object") {
6181
- (0, util_1.eachItem)(schema[key], (sch) => count += countKeys(sch));
6181
+ (0, util_1.eachItem)(schema[key], (sch) => count2 += countKeys(sch));
6182
6182
  }
6183
- if (count === Infinity)
6183
+ if (count2 === Infinity)
6184
6184
  return Infinity;
6185
6185
  }
6186
- return count;
6186
+ return count2;
6187
6187
  }
6188
6188
  function getFullPath(resolver, id = "", normalize2) {
6189
6189
  if (normalize2 !== false)
@@ -9228,8 +9228,8 @@ var require_contains = __commonJS({
9228
9228
  cxt.result(valid, () => cxt.reset());
9229
9229
  function validateItemsWithCount() {
9230
9230
  const schValid = gen.name("_valid");
9231
- const count = gen.let("count", 0);
9232
- validateItems(schValid, () => gen.if(schValid, () => checkLimits(count)));
9231
+ const count2 = gen.let("count", 0);
9232
+ validateItems(schValid, () => gen.if(schValid, () => checkLimits(count2)));
9233
9233
  }
9234
9234
  function validateItems(_valid, block) {
9235
9235
  gen.forRange("i", 0, len, (i6) => {
@@ -9242,16 +9242,16 @@ var require_contains = __commonJS({
9242
9242
  block();
9243
9243
  });
9244
9244
  }
9245
- function checkLimits(count) {
9246
- gen.code((0, codegen_1._)`${count}++`);
9245
+ function checkLimits(count2) {
9246
+ gen.code((0, codegen_1._)`${count2}++`);
9247
9247
  if (max === void 0) {
9248
- gen.if((0, codegen_1._)`${count} >= ${min}`, () => gen.assign(valid, true).break());
9248
+ gen.if((0, codegen_1._)`${count2} >= ${min}`, () => gen.assign(valid, true).break());
9249
9249
  } else {
9250
- gen.if((0, codegen_1._)`${count} > ${max}`, () => gen.assign(valid, false).break());
9250
+ gen.if((0, codegen_1._)`${count2} > ${max}`, () => gen.assign(valid, false).break());
9251
9251
  if (min === 1)
9252
9252
  gen.assign(valid, true);
9253
9253
  else
9254
- gen.if((0, codegen_1._)`${count} >= ${min}`, () => gen.assign(valid, true));
9254
+ gen.if((0, codegen_1._)`${count2} >= ${min}`, () => gen.assign(valid, true));
9255
9255
  }
9256
9256
  }
9257
9257
  }
@@ -15018,8 +15018,8 @@ var init_az = __esm({
15018
15018
  });
15019
15019
 
15020
15020
  // ../../node_modules/zod/v4/locales/be.js
15021
- function getBelarusianPlural(count, one, few, many) {
15022
- const absCount = Math.abs(count);
15021
+ function getBelarusianPlural(count2, one, few, many) {
15022
+ const absCount = Math.abs(count2);
15023
15023
  const lastDigit = absCount % 10;
15024
15024
  const lastTwoDigits = absCount % 100;
15025
15025
  if (lastTwoDigits >= 11 && lastTwoDigits <= 19) {
@@ -16933,8 +16933,8 @@ var init_hu = __esm({
16933
16933
  });
16934
16934
 
16935
16935
  // ../../node_modules/zod/v4/locales/hy.js
16936
- function getArmenianPlural(count, one, many) {
16937
- return Math.abs(count) === 1 ? one : many;
16936
+ function getArmenianPlural(count2, one, many) {
16937
+ return Math.abs(count2) === 1 ? one : many;
16938
16938
  }
16939
16939
  function withDefiniteArticle(word) {
16940
16940
  if (!word)
@@ -19049,8 +19049,8 @@ var init_pt = __esm({
19049
19049
  });
19050
19050
 
19051
19051
  // ../../node_modules/zod/v4/locales/ru.js
19052
- function getRussianPlural(count, one, few, many) {
19053
- const absCount = Math.abs(count);
19052
+ function getRussianPlural(count2, one, few, many) {
19053
+ const absCount = Math.abs(count2);
19054
19054
  const lastDigit = absCount % 10;
19055
19055
  const lastTwoDigits = absCount % 100;
19056
19056
  if (lastTwoDigits >= 11 && lastTwoDigits <= 19) {
@@ -59475,9 +59475,9 @@ var init_DefaultRetryToken = __esm({
59475
59475
  count;
59476
59476
  cost;
59477
59477
  longPoll;
59478
- constructor(delay2, count, cost, longPoll) {
59478
+ constructor(delay2, count2, cost, longPoll) {
59479
59479
  this.delay = delay2;
59480
- this.count = count;
59480
+ this.count = count2;
59481
59481
  this.cost = cost;
59482
59482
  this.longPoll = longPoll;
59483
59483
  }
@@ -62418,9 +62418,9 @@ function validateAmpersand(xmlData, i6) {
62418
62418
  i6++;
62419
62419
  return validateNumberAmpersand(xmlData, i6);
62420
62420
  }
62421
- let count = 0;
62422
- for (; i6 < xmlData.length; i6++, count++) {
62423
- if (xmlData[i6].match(/\w/) && count < 20)
62421
+ let count2 = 0;
62422
+ for (; i6 < xmlData.length; i6++, count2++) {
62423
+ if (xmlData[i6].match(/\w/) && count2 < 20)
62424
62424
  continue;
62425
62425
  if (xmlData[i6] === ";")
62426
62426
  break;
@@ -65059,8 +65059,8 @@ var init_Matcher = __esm({
65059
65059
  const siblingKey = namespace ? `${namespace}:${tagName}` : tagName;
65060
65060
  const counter = siblings.get(siblingKey) || 0;
65061
65061
  let position = 0;
65062
- for (const count of siblings.values()) {
65063
- position += count;
65062
+ for (const count2 of siblings.values()) {
65063
+ position += count2;
65064
65064
  }
65065
65065
  siblings.set(siblingKey, counter + 1);
65066
65066
  const node = {
@@ -86413,6 +86413,11 @@ function mapRelationalRow(tablesConfig, tableConfig, row, buildQueryResultSelect
86413
86413
  return result;
86414
86414
  }
86415
86415
 
86416
+ // ../../node_modules/drizzle-orm/sql/functions/aggregate.js
86417
+ function count(expression) {
86418
+ return sql`count(${sql.raw("*")})`.mapWith(Number);
86419
+ }
86420
+
86416
86421
  // ../../node_modules/postgres/src/query.js
86417
86422
  var originCache = /* @__PURE__ */ new Map();
86418
86423
  var originStackCache = /* @__PURE__ */ new Map();
@@ -105727,6 +105732,70 @@ var TOOLS = [
105727
105732
  required: ["name"]
105728
105733
  }
105729
105734
  },
105735
+ {
105736
+ name: "update-tag",
105737
+ description: "Rename a team tag and/or change its scope. Provide `name` to rename (a case-insensitive name collision in the same scope is rejected \u2014 use merge-tags instead), and/or `projectId` to re-scope (a project UUID, or null for a general team tag). Existing ticket/customer/project/transaction tag relations are preserved. Find tag ids via get-tags.",
105738
+ inputSchema: {
105739
+ type: "object",
105740
+ properties: {
105741
+ teamId: teamIdProp,
105742
+ tagId: { type: "string", description: "Tag ID (UUID) to update" },
105743
+ name: { type: "string", description: "New tag name" },
105744
+ projectId: {
105745
+ type: ["string", "null"],
105746
+ description: "Project UUID to make it project-specific, or null for a general team tag"
105747
+ }
105748
+ },
105749
+ required: ["tagId"]
105750
+ }
105751
+ },
105752
+ {
105753
+ name: "delete-tag",
105754
+ description: "Delete a team tag SAFELY. With mode 'delete_if_unused' (default) the tag is hard-deleted only when it is not used by any ticket/customer/project/transaction; if it is still used, the call is refused and usage counts are returned (it never strips the tag off entities). Mode 'archive' is not supported (the tags table has no archived column) \u2014 use merge-tags to fold a used tag into another, then delete the empty tag.",
105755
+ inputSchema: {
105756
+ type: "object",
105757
+ properties: {
105758
+ teamId: teamIdProp,
105759
+ tagId: { type: "string", description: "Tag ID (UUID) to delete" },
105760
+ mode: {
105761
+ type: "string",
105762
+ enum: ["delete_if_unused", "archive"],
105763
+ default: "delete_if_unused",
105764
+ description: "delete_if_unused = hard-delete only when unused (default); archive = unsupported, returns guidance"
105765
+ }
105766
+ },
105767
+ required: ["tagId"]
105768
+ }
105769
+ },
105770
+ {
105771
+ name: "merge-tags",
105772
+ description: "Merge one or more duplicate/misspelled source tags into a single target tag. Specify the target by `targetTagId` (existing) or `targetName` (reused if it exists, otherwise created as a general team tag). All ticket/customer/project/transaction relations are moved onto the target without creating duplicates (entities that already have the target keep a single tag). By default the empty source tags are deleted afterwards (set deleteSources=false to keep them). Returns moved/skipped counts per entity type. Find tag ids via get-tags.",
105773
+ inputSchema: {
105774
+ type: "object",
105775
+ properties: {
105776
+ teamId: teamIdProp,
105777
+ sourceTagIds: {
105778
+ type: "array",
105779
+ items: { type: "string" },
105780
+ description: "Tag IDs to merge away (at least one)"
105781
+ },
105782
+ targetTagId: {
105783
+ type: "string",
105784
+ description: "Existing target tag ID (takes precedence over targetName)"
105785
+ },
105786
+ targetName: {
105787
+ type: "string",
105788
+ description: "Target tag name; an existing match is reused, otherwise a general tag is created"
105789
+ },
105790
+ deleteSources: {
105791
+ type: "boolean",
105792
+ default: true,
105793
+ description: "Delete the now-empty source tags after merging"
105794
+ }
105795
+ },
105796
+ required: ["sourceTagIds"]
105797
+ }
105798
+ },
105730
105799
  {
105731
105800
  name: "get-calendar-items",
105732
105801
  description: "List agenda/calendar items (deadlines, meetings, reminders, deliveries) with optional filters by date range, project, ticket, customer, assignee, type, or status. Returns items with id, title, startsAt, endsAt, dueDate, linked ticket/project/customer ids, and status.",
@@ -105935,13 +106004,19 @@ var TOOLS = [
105935
106004
  },
105936
106005
  {
105937
106006
  name: "get-projects",
105938
- description: "Get projects with optional filtering",
106007
+ description: "Get projects with optional filtering. Each project includes its ID and, when archived, its archive timestamp/reason. Archived projects are hidden by default; pass status 'archived' or 'all' to include them.",
105939
106008
  inputSchema: {
105940
106009
  type: "object",
105941
106010
  properties: {
105942
106011
  teamId: teamIdProp,
105943
106012
  customerId: { type: "string", description: "Filter by customer ID" },
105944
106013
  q: { type: "string", description: "Search query for project name" },
106014
+ status: {
106015
+ type: "string",
106016
+ enum: ["active", "archived", "all"],
106017
+ default: "active",
106018
+ description: "Archive filter: 'active' (default, hides archived), 'archived', or 'all'."
106019
+ },
105945
106020
  pageSize: { type: "number", default: 20, maximum: 100 }
105946
106021
  },
105947
106022
  required: []
@@ -105968,7 +106043,7 @@ var TOOLS = [
105968
106043
  },
105969
106044
  {
105970
106045
  name: "update-project",
105971
- description: "Update an existing project's fields (name, description, customer, rate, currency, billable, estimate, internal). Only provided fields change. Renaming a project renumbers its tickets. There is no project 'status' field. Find the project id via get-projects.",
106046
+ description: "Update an existing project's fields (name, description, customer, rate, currency, billable, estimate, internal). Only provided fields change. Renaming a project renumbers its tickets. To retire a mistakenly-created project use archive-project (reversible) or delete-project (empty projects only). Find the project id via get-projects.",
105972
106047
  inputSchema: {
105973
106048
  type: "object",
105974
106049
  properties: {
@@ -105995,6 +106070,38 @@ var TOOLS = [
105995
106070
  required: ["id"]
105996
106071
  }
105997
106072
  },
106073
+ {
106074
+ name: "archive-project",
106075
+ description: "Safely archive (soft-retire) a project \u2014 the recommended way to clean up a mistakenly-created project. Reversible and non-destructive: it keeps all tickets, hours, trips, documents and other data, and only hides the project from get-projects by default. Use this instead of delete-project whenever a project has any history. Find the project id via get-projects. Note: the archive flag is stored in projects.settings.archivedAt; the dashboard UI does not yet read it, so the project still appears there.",
106076
+ inputSchema: {
106077
+ type: "object",
106078
+ properties: {
106079
+ teamId: teamIdProp,
106080
+ projectId: { type: "string", description: "Project ID to archive" },
106081
+ reason: {
106082
+ type: "string",
106083
+ description: "Optional note explaining why the project is archived"
106084
+ }
106085
+ },
106086
+ required: ["projectId"]
106087
+ }
106088
+ },
106089
+ {
106090
+ name: "delete-project",
106091
+ description: "Permanently hard-delete a project, but ONLY when it is empty (no tickets, agenda/time entries, timesheet templates, trips, or trip templates). If any such dependencies exist the delete is rejected with a dependency summary \u2014 archive-project instead. Requires team OWNER privileges and confirmEmptyOnly: true as an explicit safety interlock. Prefer archive-project unless you are certain the project should be erased.",
106092
+ inputSchema: {
106093
+ type: "object",
106094
+ properties: {
106095
+ teamId: teamIdProp,
106096
+ projectId: { type: "string", description: "Project ID to delete" },
106097
+ confirmEmptyOnly: {
106098
+ type: "boolean",
106099
+ description: "Must be true to authorise the hard delete of an empty project."
106100
+ }
106101
+ },
106102
+ required: ["projectId"]
106103
+ }
106104
+ },
105998
106105
  {
105999
106106
  name: "get-project-members",
106000
106107
  description: "List the members explicitly assigned to a project (member_project_access) plus the full team roster with each member's effective access. Use the returned userIds with set/add/remove-project-member. Access model: owners and members with no restrictions see ALL projects; once a member is explicitly assigned to ANY project they can ONLY see their explicitly-assigned projects.",
@@ -107985,7 +108092,7 @@ async function applyHumanizer(blocks, mode) {
107985
108092
  for (const change of rules.report) {
107986
108093
  byRule.set(change.rule, (byRule.get(change.rule) ?? 0) + 1);
107987
108094
  }
107988
- const ruleSummary = [...byRule.entries()].map(([rule, count]) => `${rule} \xD7${count}`).join(", ");
108095
+ const ruleSummary = [...byRule.entries()].map(([rule, count2]) => `${rule} \xD7${count2}`).join(", ");
107989
108096
  lines.push(
107990
108097
  `Humanizer (rules): ${rules.report.length} aanpassing(en) \u2014 ${ruleSummary}.`
107991
108098
  );
@@ -112565,10 +112672,59 @@ The document can now be selected as a PDF attachment when sending this invoice f
112565
112672
  };
112566
112673
  }
112567
112674
 
112675
+ // src/tools/project-cleanup-util.ts
112676
+ var PROJECT_STATUS_FILTERS = [
112677
+ "active",
112678
+ "archived",
112679
+ "all"
112680
+ ];
112681
+ var DEPENDENCY_LABELS = {
112682
+ tickets: "ticket(s)",
112683
+ timesheetEvents: "agenda/time entr(ies)",
112684
+ timesheetTemplates: "timesheet template(s)",
112685
+ trips: "trip(s)",
112686
+ tripTemplates: "trip template(s)"
112687
+ };
112688
+ function getProjectArchiveState(settings) {
112689
+ const obj = settings && typeof settings === "object" && !Array.isArray(settings) ? settings : {};
112690
+ const archivedAt = typeof obj.archivedAt === "string" && obj.archivedAt.trim().length > 0 ? obj.archivedAt : null;
112691
+ const archiveReason = typeof obj.archiveReason === "string" && obj.archiveReason.trim().length > 0 ? obj.archiveReason : null;
112692
+ return { archived: archivedAt !== null, archivedAt, archiveReason };
112693
+ }
112694
+ function withArchiveSettings(settings, archivedAt, reason) {
112695
+ const base = settings && typeof settings === "object" && !Array.isArray(settings) ? { ...settings } : {};
112696
+ base.archivedAt = archivedAt;
112697
+ if (reason && reason.trim().length > 0) {
112698
+ base.archiveReason = reason.trim();
112699
+ }
112700
+ return base;
112701
+ }
112702
+ function totalProjectDependencies(counts) {
112703
+ return counts.tickets + counts.timesheetEvents + counts.timesheetTemplates + counts.trips + counts.tripTemplates;
112704
+ }
112705
+ function isProjectEmpty(counts) {
112706
+ return totalProjectDependencies(counts) === 0;
112707
+ }
112708
+ function formatProjectDependencies(counts) {
112709
+ const parts = Object.keys(DEPENDENCY_LABELS).filter((key) => counts[key] > 0).map((key) => `${counts[key]} ${DEPENDENCY_LABELS[key]}`);
112710
+ return parts.length > 0 ? parts.join(", ") : "no dependencies";
112711
+ }
112712
+
112568
112713
  // src/tools/projects.ts
112569
112714
  async function handleGetProjects(input) {
112570
112715
  const ctx = getAuthContext();
112571
112716
  const { customerId, q: q3, pageSize = 20 } = input;
112717
+ const status = input.status ?? "active";
112718
+ if (!PROJECT_STATUS_FILTERS.includes(status)) {
112719
+ return {
112720
+ content: [
112721
+ {
112722
+ type: "text",
112723
+ text: `Error: invalid status "${status}". Allowed: ${PROJECT_STATUS_FILTERS.join(", ")}.`
112724
+ }
112725
+ ]
112726
+ };
112727
+ }
112572
112728
  const resolved = await resolveTeamId(input.teamId);
112573
112729
  if (!resolved.ok) return resolved.response;
112574
112730
  const projectIds = await getAccessibleProjectIds(ctx.userId, resolved.teamId);
@@ -112585,25 +112741,33 @@ async function handleGetProjects(input) {
112585
112741
  const filters = [inArray(schema_exports.projects.id, projectIds)];
112586
112742
  if (customerId) filters.push(eq(schema_exports.projects.customerId, customerId));
112587
112743
  if (q3) filters.push(ilike(schema_exports.projects.name, `%${q3}%`));
112744
+ if (status === "active") {
112745
+ filters.push(sql`${schema_exports.projects.settings} ->> 'archivedAt' IS NULL`);
112746
+ } else if (status === "archived") {
112747
+ filters.push(sql`${schema_exports.projects.settings} ->> 'archivedAt' IS NOT NULL`);
112748
+ }
112588
112749
  const rows = await db.select({
112589
112750
  id: schema_exports.projects.id,
112590
112751
  name: schema_exports.projects.name,
112591
112752
  description: schema_exports.projects.description,
112592
112753
  customerId: schema_exports.projects.customerId,
112593
- createdAt: schema_exports.projects.createdAt
112754
+ createdAt: schema_exports.projects.createdAt,
112755
+ settings: schema_exports.projects.settings
112594
112756
  }).from(schema_exports.projects).where(and(...filters)).orderBy(asc(schema_exports.projects.name)).limit(Math.min(pageSize, 100));
112595
112757
  return {
112596
112758
  content: [
112597
112759
  {
112598
112760
  type: "text",
112599
- text: `Found ${rows.length} projects:
112761
+ text: `Found ${rows.length} project(s)${status !== "all" ? ` (status: ${status})` : ""}:
112600
112762
 
112601
- ${rows.map(
112602
- (p3) => `**${p3.name}** (ID: ${p3.id})
112763
+ ${rows.map((p3) => {
112764
+ const archive = getProjectArchiveState(p3.settings);
112765
+ return `**${p3.name}** (ID: ${p3.id})${archive.archived ? " \u2014 ARCHIVED" : ""}
112603
112766
  ${p3.description ? `Description: ${p3.description}
112604
112767
  ` : ""}Created: ${new Date(p3.createdAt).toLocaleDateString()}
112605
- `
112606
- ).join("\n") || "No projects found."}`
112768
+ ${archive.archived ? `Archived: ${archive.archivedAt}${archive.archiveReason ? ` (${archive.archiveReason})` : ""}
112769
+ ` : ""}`;
112770
+ }).join("\n") || "No projects found."}`
112607
112771
  }
112608
112772
  ]
112609
112773
  };
@@ -113104,6 +113268,108 @@ async function handleRemoveProjectMember(input) {
113104
113268
  }
113105
113269
  return textResponse(text3);
113106
113270
  }
113271
+ async function loadProjectForCleanup(projectId, teamId) {
113272
+ const accessibleTeamIds = await getAccessibleTeamIds(teamId);
113273
+ const [row] = await db.select({
113274
+ id: schema_exports.projects.id,
113275
+ name: schema_exports.projects.name,
113276
+ teamId: schema_exports.projects.teamId,
113277
+ settings: schema_exports.projects.settings
113278
+ }).from(schema_exports.projects).where(eq(schema_exports.projects.id, projectId)).limit(1);
113279
+ if (!row || !row.teamId || !accessibleTeamIds.includes(row.teamId)) {
113280
+ return null;
113281
+ }
113282
+ return { id: row.id, name: row.name, teamId: row.teamId, settings: row.settings };
113283
+ }
113284
+ async function countProjectDependencies(projectId) {
113285
+ const countRows = (table) => db.select({ c: sql`count(*)::int` }).from(table).where(eq(table.projectId, projectId)).then((r6) => r6[0]?.c ?? 0);
113286
+ const [tickets3, timesheetEvents2, timesheetTemplates2, trips2, tripTemplates2] = await Promise.all([
113287
+ countRows(schema_exports.tickets),
113288
+ countRows(schema_exports.timesheetEvents),
113289
+ countRows(schema_exports.timesheetTemplates),
113290
+ countRows(schema_exports.trips),
113291
+ countRows(schema_exports.tripTemplates)
113292
+ ]);
113293
+ return { tickets: tickets3, timesheetEvents: timesheetEvents2, timesheetTemplates: timesheetTemplates2, trips: trips2, tripTemplates: tripTemplates2 };
113294
+ }
113295
+ async function handleArchiveProject(input) {
113296
+ const { projectId, reason } = input;
113297
+ if (!projectId) return textResponse("Error: `projectId` is required.");
113298
+ const resolved = await resolveTeamId(input.teamId);
113299
+ if (!resolved.ok) return resolved.response;
113300
+ const project = await loadProjectForCleanup(projectId, resolved.teamId);
113301
+ if (!project) {
113302
+ return textResponse(
113303
+ `Project ${projectId} not found, or it is not owned by this team.`
113304
+ );
113305
+ }
113306
+ const state2 = getProjectArchiveState(project.settings);
113307
+ if (state2.archived) {
113308
+ return textResponse(
113309
+ `Project "${project.name}" (${project.id}) is already archived${state2.archivedAt ? ` (since ${state2.archivedAt})` : ""}.`
113310
+ );
113311
+ }
113312
+ const archivedAt = (/* @__PURE__ */ new Date()).toISOString();
113313
+ const nextSettings = withArchiveSettings(project.settings, archivedAt, reason);
113314
+ await db.update(schema_exports.projects).set({ settings: nextSettings, updatedAt: sql`now()` }).where(eq(schema_exports.projects.id, project.id));
113315
+ return textResponse(
113316
+ `\u2705 **Project archived**
113317
+
113318
+ Project: ${project.name}
113319
+ ID: ${project.id}
113320
+ Action: archived (soft, reversible)
113321
+ Status: archived
113322
+ Timestamp: ${archivedAt}
113323
+ ${reason ? `Reason: ${reason}
113324
+ ` : ""}
113325
+ Archived projects are hidden from get-projects by default (pass status: 'archived' or 'all' to see them). No tickets, hours, or other data were touched.
113326
+
113327
+ Note: the archive flag is stored in \`projects.settings.archivedAt\`; the dashboard UI does not yet read it, so the project still appears there.`
113328
+ );
113329
+ }
113330
+ async function handleDeleteProject(input) {
113331
+ const ctx = getAuthContext();
113332
+ const { projectId, confirmEmptyOnly } = input;
113333
+ if (!projectId) return textResponse("Error: `projectId` is required.");
113334
+ const resolved = await resolveTeamId(input.teamId);
113335
+ if (!resolved.ok) return resolved.response;
113336
+ const ownerError = await requireTeamOwner(resolved.teamId, ctx.userId);
113337
+ if (ownerError) return ownerError;
113338
+ const project = await loadProjectForCleanup(projectId, resolved.teamId);
113339
+ if (!project) {
113340
+ return textResponse(
113341
+ `Project ${projectId} not found, or it is not owned by this team.`
113342
+ );
113343
+ }
113344
+ const deps = await countProjectDependencies(project.id);
113345
+ const summary = formatProjectDependencies(deps);
113346
+ if (!isProjectEmpty(deps)) {
113347
+ return textResponse(
113348
+ `\u{1F6AB} **Delete blocked** \u2014 project "${project.name}" (${project.id}) is not empty.
113349
+
113350
+ Dependencies: ${summary}.
113351
+
113352
+ A hard delete would orphan these records, so it is not allowed. Use archive-project instead to safely retire this project (reversible, keeps all data).`
113353
+ );
113354
+ }
113355
+ if (confirmEmptyOnly !== true) {
113356
+ return textResponse(
113357
+ `Project "${project.name}" (${project.id}) has no dependencies and can be safely deleted. This is a permanent hard delete. Re-run delete-project with confirmEmptyOnly: true to proceed (or use archive-project to keep the record).`
113358
+ );
113359
+ }
113360
+ await db.delete(schema_exports.projects).where(eq(schema_exports.projects.id, project.id));
113361
+ return textResponse(
113362
+ `\u2705 **Project deleted**
113363
+
113364
+ Project: ${project.name}
113365
+ ID: ${project.id}
113366
+ Action: hard delete (empty project)
113367
+ Status: deleted
113368
+ Timestamp: ${(/* @__PURE__ */ new Date()).toISOString()}
113369
+
113370
+ The project had no tickets, hours, trips, or templates. Any project-scoped config (member access, tags, slack/github links, team rates) was removed with it.`
113371
+ );
113372
+ }
113107
113373
 
113108
113374
  // src/tools/products.ts
113109
113375
  var PRODUCT_STATUSES = ["active", "archived", "all"];
@@ -119314,11 +119580,11 @@ async function handleCreateTag(input) {
119314
119580
  const resolved = await resolveTeamId(input.teamId);
119315
119581
  if (!resolved.ok) return resolved.response;
119316
119582
  const normalized = normalizeTagName(name21);
119317
- const scopeFilter = input.projectId ? eq(schema_exports.tags.projectId, input.projectId) : isNull(schema_exports.tags.projectId);
119583
+ const scopeFilter2 = input.projectId ? eq(schema_exports.tags.projectId, input.projectId) : isNull(schema_exports.tags.projectId);
119318
119584
  const [existing] = await db.select({ id: schema_exports.tags.id, name: schema_exports.tags.name }).from(schema_exports.tags).where(
119319
119585
  and(
119320
119586
  eq(schema_exports.tags.teamId, resolved.teamId),
119321
- scopeFilter,
119587
+ scopeFilter2,
119322
119588
  sql`lower(${schema_exports.tags.name}) = ${normalized}`
119323
119589
  )
119324
119590
  ).limit(1);
@@ -119364,6 +119630,346 @@ ${created.projectId ? `Project ID: ${created.projectId}
119364
119630
  };
119365
119631
  }
119366
119632
 
119633
+ // src/tools/tag-merge-util.ts
119634
+ function planRelationMerge(sourceRows, targetEntityIds) {
119635
+ const targetSet = new Set(targetEntityIds);
119636
+ const teamByEntity = /* @__PURE__ */ new Map();
119637
+ for (const row of sourceRows) {
119638
+ if (!teamByEntity.has(row.entityId)) {
119639
+ teamByEntity.set(row.entityId, row.teamId);
119640
+ }
119641
+ }
119642
+ const toInsert = [];
119643
+ let skippedDuplicates = 0;
119644
+ for (const [entityId, teamId] of teamByEntity) {
119645
+ if (targetSet.has(entityId)) {
119646
+ skippedDuplicates += 1;
119647
+ } else {
119648
+ toInsert.push({ entityId, teamId });
119649
+ }
119650
+ }
119651
+ return {
119652
+ toInsert,
119653
+ skippedDuplicates,
119654
+ sourceEntityCount: teamByEntity.size
119655
+ };
119656
+ }
119657
+ function isValidTagName(name21) {
119658
+ return typeof name21 === "string" && name21.trim().length > 0;
119659
+ }
119660
+ function totalTagUsage(usage) {
119661
+ return usage.tickets + usage.customers + usage.projects + usage.transactions;
119662
+ }
119663
+ function formatTagUsage(usage) {
119664
+ const parts = [];
119665
+ if (usage.tickets) parts.push(`${usage.tickets} ticket(s)`);
119666
+ if (usage.customers) parts.push(`${usage.customers} customer(s)`);
119667
+ if (usage.projects) parts.push(`${usage.projects} project(s)`);
119668
+ if (usage.transactions) parts.push(`${usage.transactions} transaction(s)`);
119669
+ return parts.length > 0 ? parts.join(", ") : "no entities";
119670
+ }
119671
+
119672
+ // src/tools/tag-management.ts
119673
+ function textResponse4(text3) {
119674
+ return { content: [{ type: "text", text: text3 }] };
119675
+ }
119676
+ var TAG_COLUMNS = {
119677
+ id: schema_exports.tags.id,
119678
+ name: schema_exports.tags.name,
119679
+ teamId: schema_exports.tags.teamId,
119680
+ projectId: schema_exports.tags.projectId,
119681
+ createdAt: schema_exports.tags.createdAt
119682
+ };
119683
+ function describeTag(tag) {
119684
+ return `**${tag.name}** (id: ${tag.id})${tag.projectId ? ` [project-specific: ${tag.projectId}]` : " [general]"}`;
119685
+ }
119686
+ async function loadTagInTeam(tagId, teamId) {
119687
+ const accessibleTeamIds = await getAccessibleTeamIds(teamId);
119688
+ const [row] = await db.select(TAG_COLUMNS).from(schema_exports.tags).where(
119689
+ and(
119690
+ eq(schema_exports.tags.id, tagId),
119691
+ inArray(schema_exports.tags.teamId, accessibleTeamIds)
119692
+ )
119693
+ ).limit(1);
119694
+ return row ?? null;
119695
+ }
119696
+ async function getTagUsage(tagId) {
119697
+ const [tickets3, customers2, projects2, transactions2] = await Promise.all([
119698
+ db.select({ value: count() }).from(schema_exports.ticketTags).where(eq(schema_exports.ticketTags.tagId, tagId)),
119699
+ db.select({ value: count() }).from(schema_exports.customerTags).where(eq(schema_exports.customerTags.tagId, tagId)),
119700
+ db.select({ value: count() }).from(schema_exports.projectTags).where(eq(schema_exports.projectTags.tagId, tagId)),
119701
+ db.select({ value: count() }).from(schema_exports.transactionTags).where(eq(schema_exports.transactionTags.tagId, tagId))
119702
+ ]);
119703
+ return {
119704
+ tickets: Number(tickets3[0]?.value ?? 0),
119705
+ customers: Number(customers2[0]?.value ?? 0),
119706
+ projects: Number(projects2[0]?.value ?? 0),
119707
+ transactions: Number(transactions2[0]?.value ?? 0)
119708
+ };
119709
+ }
119710
+ function scopeFilter(projectId) {
119711
+ return projectId === null ? isNull(schema_exports.tags.projectId) : eq(schema_exports.tags.projectId, projectId);
119712
+ }
119713
+ async function handleUpdateTag(input) {
119714
+ if (!input.tagId) return textResponse4("Error: `tagId` is required.");
119715
+ const resolved = await resolveTeamId(input.teamId);
119716
+ if (!resolved.ok) return resolved.response;
119717
+ const existing = await loadTagInTeam(input.tagId, resolved.teamId);
119718
+ if (!existing) {
119719
+ return textResponse4(
119720
+ `Tag ${input.tagId} not found, or it is not owned by this team.`
119721
+ );
119722
+ }
119723
+ const renaming = input.name !== void 0;
119724
+ const rescoping = input.projectId !== void 0;
119725
+ if (!renaming && !rescoping) {
119726
+ return textResponse4(
119727
+ "No changes requested. Provide `name` to rename and/or `projectId` (string, or null for a general tag) to change scope."
119728
+ );
119729
+ }
119730
+ if (renaming && !isValidTagName(input.name)) {
119731
+ return textResponse4("Error: `name` cannot be empty.");
119732
+ }
119733
+ const nextName = renaming ? input.name.trim() : existing.name;
119734
+ const nextProjectId = rescoping ? input.projectId ?? null : existing.projectId;
119735
+ const [collision] = await db.select({ id: schema_exports.tags.id, name: schema_exports.tags.name }).from(schema_exports.tags).where(
119736
+ and(
119737
+ eq(schema_exports.tags.teamId, existing.teamId),
119738
+ scopeFilter(nextProjectId),
119739
+ sql`lower(${schema_exports.tags.name}) = ${normalizeTagName(nextName)}`,
119740
+ ne(schema_exports.tags.id, existing.id)
119741
+ )
119742
+ ).limit(1);
119743
+ if (collision) {
119744
+ return textResponse4(
119745
+ `\u274C Cannot update: another tag already uses the name "${collision.name}" (id: ${collision.id}) in this scope. Use merge-tags to combine them instead of renaming.`
119746
+ );
119747
+ }
119748
+ const [updated] = await db.update(schema_exports.tags).set({ name: nextName, projectId: nextProjectId }).where(eq(schema_exports.tags.id, existing.id)).returning(TAG_COLUMNS);
119749
+ if (!updated) return textResponse4(`Failed to update tag ${input.tagId}.`);
119750
+ return textResponse4(
119751
+ `\u2705 **Tag updated**
119752
+
119753
+ ${describeTag(updated)}
119754
+
119755
+ Existing ticket/customer/project/transaction tag relations are preserved.`
119756
+ );
119757
+ }
119758
+ async function handleDeleteTag(input) {
119759
+ if (!input.tagId) return textResponse4("Error: `tagId` is required.");
119760
+ const mode = input.mode ?? "delete_if_unused";
119761
+ const resolved = await resolveTeamId(input.teamId);
119762
+ if (!resolved.ok) return resolved.response;
119763
+ const existing = await loadTagInTeam(input.tagId, resolved.teamId);
119764
+ if (!existing) {
119765
+ return textResponse4(
119766
+ `Tag ${input.tagId} not found, or it is not owned by this team.`
119767
+ );
119768
+ }
119769
+ const usage = await getTagUsage(existing.id);
119770
+ const total = totalTagUsage(usage);
119771
+ if (mode === "archive") {
119772
+ return textResponse4(
119773
+ `\u2139\uFE0F Archiving is not supported for team tags: the \`tags\` table has no archived column. ${describeTag(existing)} is used by ${formatTagUsage(usage)}.
119774
+
119775
+ Options: use merge-tags to fold it into another tag, or delete it once it is unused (mode: delete_if_unused).`
119776
+ );
119777
+ }
119778
+ if (total > 0) {
119779
+ return textResponse4(
119780
+ `\u274C Refusing to delete ${describeTag(existing)}: it is still used by ${formatTagUsage(usage)}. Deleting would strip the tag off those entities.
119781
+
119782
+ Use merge-tags to move usage onto another tag first, then delete the (now-empty) tag.`
119783
+ );
119784
+ }
119785
+ await db.delete(schema_exports.tags).where(eq(schema_exports.tags.id, existing.id));
119786
+ return textResponse4(
119787
+ `\u2705 **Tag deleted** (was unused): ${describeTag(existing)}`
119788
+ );
119789
+ }
119790
+ async function resolveMergeTarget(teamId, input) {
119791
+ if (input.targetTagId) {
119792
+ const tag = await loadTagInTeam(input.targetTagId, teamId);
119793
+ if (!tag) {
119794
+ return {
119795
+ ok: false,
119796
+ response: textResponse4(
119797
+ `Target tag ${input.targetTagId} not found, or it is not owned by this team.`
119798
+ )
119799
+ };
119800
+ }
119801
+ return { ok: true, tag, created: false };
119802
+ }
119803
+ if (!isValidTagName(input.targetName)) {
119804
+ return {
119805
+ ok: false,
119806
+ response: textResponse4(
119807
+ "Error: provide either `targetTagId` or a non-empty `targetName`."
119808
+ )
119809
+ };
119810
+ }
119811
+ const normalized = normalizeTagName(input.targetName);
119812
+ const matches = await db.select(TAG_COLUMNS).from(schema_exports.tags).where(
119813
+ and(
119814
+ eq(schema_exports.tags.teamId, teamId),
119815
+ sql`lower(${schema_exports.tags.name}) = ${normalized}`
119816
+ )
119817
+ );
119818
+ if (matches.length > 0) {
119819
+ const general = matches.find((t8) => t8.projectId === null);
119820
+ return { ok: true, tag: general ?? matches[0], created: false };
119821
+ }
119822
+ const [created] = await db.insert(schema_exports.tags).values({ teamId, name: input.targetName.trim(), projectId: null }).returning(TAG_COLUMNS);
119823
+ if (!created) {
119824
+ return { ok: false, response: textResponse4("Failed to create target tag.") };
119825
+ }
119826
+ return { ok: true, tag: created, created: true };
119827
+ }
119828
+ async function handleMergeTags(input) {
119829
+ const rawSourceIds = [...new Set(input.sourceTagIds ?? [])].filter(Boolean);
119830
+ if (rawSourceIds.length === 0) {
119831
+ return textResponse4("Error: `sourceTagIds` must contain at least one tag id.");
119832
+ }
119833
+ const resolved = await resolveTeamId(input.teamId);
119834
+ if (!resolved.ok) return resolved.response;
119835
+ const accessibleTeamIds = await getAccessibleTeamIds(resolved.teamId);
119836
+ const sourceTags = await db.select(TAG_COLUMNS).from(schema_exports.tags).where(
119837
+ and(
119838
+ inArray(schema_exports.tags.id, rawSourceIds),
119839
+ inArray(schema_exports.tags.teamId, accessibleTeamIds)
119840
+ )
119841
+ );
119842
+ const foundIds = new Set(sourceTags.map((t8) => t8.id));
119843
+ const missing = rawSourceIds.filter((id) => !foundIds.has(id));
119844
+ if (missing.length > 0) {
119845
+ return textResponse4(
119846
+ `Error: source tag(s) not found or not owned by this team: ${missing.join(", ")}.`
119847
+ );
119848
+ }
119849
+ const target = await resolveMergeTarget(resolved.teamId, input);
119850
+ if (!target.ok) return target.response;
119851
+ const sourcesToMerge = sourceTags.filter((t8) => t8.id !== target.tag.id);
119852
+ if (sourcesToMerge.length === 0) {
119853
+ return textResponse4(
119854
+ "Error: nothing to merge \u2014 the only source tag is the same as the target tag."
119855
+ );
119856
+ }
119857
+ const sourceIds = sourcesToMerge.map((t8) => t8.id);
119858
+ const targetId = target.tag.id;
119859
+ const deleteSources = input.deleteSources ?? true;
119860
+ const results = await db.transaction(async (tx) => {
119861
+ const ticketSrc = await tx.select({
119862
+ entityId: schema_exports.ticketTags.ticketId,
119863
+ teamId: schema_exports.ticketTags.teamId
119864
+ }).from(schema_exports.ticketTags).where(inArray(schema_exports.ticketTags.tagId, sourceIds));
119865
+ const ticketTgt = await tx.select({ entityId: schema_exports.ticketTags.ticketId }).from(schema_exports.ticketTags).where(eq(schema_exports.ticketTags.tagId, targetId));
119866
+ const ticketPlan = planRelationMerge(
119867
+ ticketSrc,
119868
+ ticketTgt.map((r6) => r6.entityId)
119869
+ );
119870
+ if (ticketPlan.toInsert.length > 0) {
119871
+ await tx.insert(schema_exports.ticketTags).values(
119872
+ ticketPlan.toInsert.map((r6) => ({
119873
+ ticketId: r6.entityId,
119874
+ tagId: targetId,
119875
+ teamId: r6.teamId
119876
+ }))
119877
+ );
119878
+ }
119879
+ await tx.delete(schema_exports.ticketTags).where(inArray(schema_exports.ticketTags.tagId, sourceIds));
119880
+ const customerSrc = await tx.select({
119881
+ entityId: schema_exports.customerTags.customerId,
119882
+ teamId: schema_exports.customerTags.teamId
119883
+ }).from(schema_exports.customerTags).where(inArray(schema_exports.customerTags.tagId, sourceIds));
119884
+ const customerTgt = await tx.select({ entityId: schema_exports.customerTags.customerId }).from(schema_exports.customerTags).where(eq(schema_exports.customerTags.tagId, targetId));
119885
+ const customerPlan = planRelationMerge(
119886
+ customerSrc,
119887
+ customerTgt.map((r6) => r6.entityId)
119888
+ );
119889
+ if (customerPlan.toInsert.length > 0) {
119890
+ await tx.insert(schema_exports.customerTags).values(
119891
+ customerPlan.toInsert.map((r6) => ({
119892
+ customerId: r6.entityId,
119893
+ tagId: targetId,
119894
+ teamId: r6.teamId
119895
+ }))
119896
+ );
119897
+ }
119898
+ await tx.delete(schema_exports.customerTags).where(inArray(schema_exports.customerTags.tagId, sourceIds));
119899
+ const projectSrc = await tx.select({
119900
+ entityId: schema_exports.projectTags.projectId,
119901
+ teamId: schema_exports.projectTags.teamId
119902
+ }).from(schema_exports.projectTags).where(inArray(schema_exports.projectTags.tagId, sourceIds));
119903
+ const projectTgt = await tx.select({ entityId: schema_exports.projectTags.projectId }).from(schema_exports.projectTags).where(eq(schema_exports.projectTags.tagId, targetId));
119904
+ const projectPlan = planRelationMerge(
119905
+ projectSrc,
119906
+ projectTgt.map((r6) => r6.entityId)
119907
+ );
119908
+ if (projectPlan.toInsert.length > 0) {
119909
+ await tx.insert(schema_exports.projectTags).values(
119910
+ projectPlan.toInsert.map((r6) => ({
119911
+ projectId: r6.entityId,
119912
+ tagId: targetId,
119913
+ teamId: r6.teamId
119914
+ }))
119915
+ );
119916
+ }
119917
+ await tx.delete(schema_exports.projectTags).where(inArray(schema_exports.projectTags.tagId, sourceIds));
119918
+ const txnSrc = await tx.select({
119919
+ entityId: schema_exports.transactionTags.transactionId,
119920
+ teamId: schema_exports.transactionTags.teamId
119921
+ }).from(schema_exports.transactionTags).where(inArray(schema_exports.transactionTags.tagId, sourceIds));
119922
+ const txnTgt = await tx.select({ entityId: schema_exports.transactionTags.transactionId }).from(schema_exports.transactionTags).where(eq(schema_exports.transactionTags.tagId, targetId));
119923
+ const txnPlan = planRelationMerge(
119924
+ txnSrc,
119925
+ txnTgt.map((r6) => r6.entityId)
119926
+ );
119927
+ if (txnPlan.toInsert.length > 0) {
119928
+ await tx.insert(schema_exports.transactionTags).values(
119929
+ txnPlan.toInsert.map((r6) => ({
119930
+ transactionId: r6.entityId,
119931
+ tagId: targetId,
119932
+ teamId: r6.teamId
119933
+ }))
119934
+ );
119935
+ }
119936
+ await tx.delete(schema_exports.transactionTags).where(inArray(schema_exports.transactionTags.tagId, sourceIds));
119937
+ if (deleteSources) {
119938
+ await tx.delete(schema_exports.tags).where(inArray(schema_exports.tags.id, sourceIds));
119939
+ }
119940
+ return {
119941
+ tickets: planToResult(ticketPlan),
119942
+ customers: planToResult(customerPlan),
119943
+ projects: planToResult(projectPlan),
119944
+ transactions: planToResult(txnPlan)
119945
+ };
119946
+ });
119947
+ const movedTotal = results.tickets.moved + results.customers.moved + results.projects.moved + results.transactions.moved;
119948
+ const skippedTotal = results.tickets.skipped + results.customers.skipped + results.projects.skipped + results.transactions.skipped;
119949
+ const line2 = (label, r6) => `- ${label}: ${r6.moved} moved, ${r6.skipped} skipped (duplicate)`;
119950
+ return textResponse4(
119951
+ `\u2705 **Tags merged** into ${describeTag(target.tag)}${target.created ? " (newly created)" : ""}
119952
+
119953
+ Sources (${sourcesToMerge.length}): ${sourcesToMerge.map((t8) => `${t8.name} (${t8.id})`).join(", ")}
119954
+
119955
+ Relations moved (duplicates skipped to keep a single tag per entity):
119956
+ ${line2("Tickets", results.tickets)}
119957
+ ${line2("Customers", results.customers)}
119958
+ ${line2("Projects", results.projects)}
119959
+ ${line2("Transactions", results.transactions)}
119960
+
119961
+ Totals: ${movedTotal} relation(s) moved, ${skippedTotal} duplicate(s) skipped.
119962
+ Source tags ${deleteSources ? "deleted" : "kept (now empty)"}: ${sourcesToMerge.map((t8) => t8.id).join(", ")}.`
119963
+ );
119964
+ }
119965
+ function planToResult(plan) {
119966
+ return {
119967
+ moved: plan.toInsert.length,
119968
+ skipped: plan.skippedDuplicates,
119969
+ total: plan.sourceEntityCount
119970
+ };
119971
+ }
119972
+
119367
119973
  // src/utils/ticket-number.ts
119368
119974
  async function isTicketNumberTaken(ticketDb, teamId, ticketNumber, excludeTicketId) {
119369
119975
  const conditions = [
@@ -120170,6 +120776,12 @@ function createMcpServer() {
120170
120776
  return await handleGetTags(asToolArgs(toolArgs));
120171
120777
  case "create-tag":
120172
120778
  return await handleCreateTag(asToolArgs(toolArgs));
120779
+ case "update-tag":
120780
+ return await handleUpdateTag(asToolArgs(toolArgs));
120781
+ case "delete-tag":
120782
+ return await handleDeleteTag(asToolArgs(toolArgs));
120783
+ case "merge-tags":
120784
+ return await handleMergeTags(asToolArgs(toolArgs));
120173
120785
  case "get-calendar-items":
120174
120786
  return await handleGetCalendarItems(
120175
120787
  asToolArgs(toolArgs)
@@ -120208,6 +120820,14 @@ function createMcpServer() {
120208
120820
  return await handleCreateProject(asToolArgs(toolArgs));
120209
120821
  case "update-project":
120210
120822
  return await handleUpdateProject(asToolArgs(toolArgs));
120823
+ case "archive-project":
120824
+ return await handleArchiveProject(
120825
+ asToolArgs(toolArgs)
120826
+ );
120827
+ case "delete-project":
120828
+ return await handleDeleteProject(
120829
+ asToolArgs(toolArgs)
120830
+ );
120211
120831
  case "get-project-members":
120212
120832
  return await handleGetProjectMembers(
120213
120833
  asToolArgs(toolArgs)