freshservice-mcp 0.1.2 → 0.2.1

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 +25 -2
  2. package/dist/index.js +433 -12
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
6
6
  [![Node.js](https://img.shields.io/badge/node-%3E%3D18-brightgreen)](https://nodejs.org)
7
7
 
8
- A [Model Context Protocol (MCP)](https://modelcontextprotocol.io) server for [Freshservice](https://freshservice.com). Manage your ITSM tickets, conversations, and agents directly from any MCP-compatible AI client (Kiro, Claude Desktop, Cursor, etc.).
8
+ A [Model Context Protocol (MCP)](https://modelcontextprotocol.io) server for [Freshservice](https://freshservice.com). Manage your ITSM tickets, changes, conversations, and agents directly from any MCP-compatible AI client (Kiro, Claude Desktop, Cursor, etc.).
9
9
 
10
10
  ## Features
11
11
 
@@ -15,6 +15,9 @@ A [Model Context Protocol (MCP)](https://modelcontextprotocol.io) server for [Fr
15
15
  - ✏️ **Create tickets** — with full field support
16
16
  - 🔄 **Update tickets** — status, priority, assignment, tags
17
17
  - 📝 **Add notes or replies** — private notes or public replies
18
+ - 🗂️ **Manage changes** — list, inspect, create, and update Changes
19
+ - 🔗 **Associate tickets with changes** — supports both Freshservice relationship directions
20
+ - 📝 **Add change notes** — add HTML-formatted notes to Changes
18
21
  - 👤 **Agent lookup** — get your own profile or search by email
19
22
  - 🔒 **Read-only mode** — disable all write operations with one env var
20
23
 
@@ -69,7 +72,7 @@ Reconnect MCP servers (or reload the window). You should now have access to all
69
72
 
70
73
  ### Read-only mode
71
74
 
72
- Set `FRESHSERVICE_READONLY` to `"true"` to prevent any modifications. In this mode, `create_ticket`, `update_ticket`, and `add_ticket_note` are not registered at all. Recommended for shared environments or autonomous agent setups against production instances.
75
+ Set `FRESHSERVICE_READONLY` to `"true"` to prevent any modifications. In this mode, only ticket, Change, and agent read tools are registered; all create, update, association, note, and reply tools are omitted. Recommended for shared environments or autonomous agent setups against production instances.
73
76
 
74
77
  ## Available Tools
75
78
 
@@ -81,6 +84,12 @@ Set `FRESHSERVICE_READONLY` to `"true"` to prevent any modifications. In this mo
81
84
  | `create_ticket` | Create a new ticket |
82
85
  | `update_ticket` | Update ticket fields |
83
86
  | `add_ticket_note` | Add a private note or public reply |
87
+ | `list_changes` | List Changes with query/view filters, sorting, and pagination |
88
+ | `get_change` | Get full details of a Change |
89
+ | `create_change` | Create a Change |
90
+ | `update_change` | Update Change fields |
91
+ | `associate_tickets_to_change` | Associate one or more tickets with a Change |
92
+ | `add_change_note` | Add a note to a Change |
84
93
  | `get_me` | Get the authenticated agent's profile |
85
94
  | `list_agents` | List agents, optionally filter by email |
86
95
 
@@ -186,6 +195,20 @@ Useful optional fields:
186
195
  - `responder_id` — assign directly to an agent
187
196
  - `tags` — categorization
188
197
 
198
+ ## Change Management
199
+
200
+ Freshservice Changes use numeric values for their standard fields:
201
+
202
+ - Status: 1=Open, 2=Planning, 3=Awaiting Approval, 4=Pending Release, 5=Pending Review, 6=Closed
203
+ - Change type: 1=Minor, 2=Standard, 3=Major, 4=Emergency
204
+ - Priority: 1=Low, 2=Medium, 3=High, 4=Urgent
205
+ - Impact: 1=Low, 2=Medium, 3=High
206
+ - Risk: 1=Low, 2=Medium, 3=High, 4=Very High
207
+
208
+ `associate_tickets_to_change` uses the association shape documented by Freshservice's Ticket API. The default relationship, `change_initiated_by_ticket`, means the ticket initiated the Change. Use `association_type: "change_initiating_ticket"` when the Change initiated the ticket. `change_id` is placed in the documented nested `display_id` field.
209
+
210
+ Because the association is applied as a ticket update, Freshservice validates the whole ticket — so on an instance where `description` is a mandatory field, a ticket with an empty description would otherwise be rejected with a 400. The tool handles this transparently: it retries once with a minimal placeholder description, and only for tickets that genuinely have none. Existing descriptions are never overwritten.
211
+
189
212
  ## Local Development
190
213
 
191
214
  ```bash
package/dist/index.js CHANGED
@@ -4581,9 +4581,9 @@ var require_codegen = __commonJS({
4581
4581
  }
4582
4582
  };
4583
4583
  var Label = class extends Node {
4584
- constructor(label) {
4584
+ constructor(label3) {
4585
4585
  super();
4586
- this.label = label;
4586
+ this.label = label3;
4587
4587
  this.names = {};
4588
4588
  }
4589
4589
  render({ _n }) {
@@ -4591,14 +4591,14 @@ var require_codegen = __commonJS({
4591
4591
  }
4592
4592
  };
4593
4593
  var Break = class extends Node {
4594
- constructor(label) {
4594
+ constructor(label3) {
4595
4595
  super();
4596
- this.label = label;
4596
+ this.label = label3;
4597
4597
  this.names = {};
4598
4598
  }
4599
4599
  render({ _n }) {
4600
- const label = this.label ? ` ${this.label}` : "";
4601
- return `break${label};` + _n;
4600
+ const label3 = this.label ? ` ${this.label}` : "";
4601
+ return `break${label3};` + _n;
4602
4602
  }
4603
4603
  };
4604
4604
  var Throw = class extends Node {
@@ -5010,12 +5010,12 @@ var require_codegen = __commonJS({
5010
5010
  return this._endBlockNode(For);
5011
5011
  }
5012
5012
  // `label` statement
5013
- label(label) {
5014
- return this._leafNode(new Label(label));
5013
+ label(label3) {
5014
+ return this._leafNode(new Label(label3));
5015
5015
  }
5016
5016
  // `break` statement
5017
- break(label) {
5018
- return this._leafNode(new Break(label));
5017
+ break(label3) {
5018
+ return this._leafNode(new Break(label3));
5019
5019
  }
5020
5020
  // `return` statement
5021
5021
  return(value) {
@@ -11127,6 +11127,90 @@ async function replyToTicket(config2, ticketId, payload) {
11127
11127
  );
11128
11128
  return data.conversation;
11129
11129
  }
11130
+ function withChangePlan(payload) {
11131
+ const { change_plan, ...rest } = payload;
11132
+ return change_plan === void 0 ? rest : { ...rest, planning_fields: { change_plan } };
11133
+ }
11134
+ async function listChanges(config2, options = {}) {
11135
+ const limit = options.limit ?? 100;
11136
+ const changes = [];
11137
+ const params = { per_page: "100" };
11138
+ if (options.query) params.query = `"${options.query}"`;
11139
+ if (options.view) params.view = options.view;
11140
+ if (options.updated_since) params.updated_since = options.updated_since;
11141
+ if (options.workspace_id !== void 0) params.workspace_id = String(options.workspace_id);
11142
+ if (options.order_by) params.order_by = options.order_by;
11143
+ if (options.order_type) params.order_type = options.order_type;
11144
+ let page = options.page ?? 1;
11145
+ while (changes.length < limit) {
11146
+ params.page = String(page);
11147
+ const data = await fsGet(config2, "changes", params);
11148
+ const batch = data.changes ?? [];
11149
+ if (batch.length === 0) break;
11150
+ for (const change of batch) {
11151
+ changes.push(change);
11152
+ if (changes.length >= limit) break;
11153
+ }
11154
+ if (batch.length < 100) break;
11155
+ page++;
11156
+ }
11157
+ return changes;
11158
+ }
11159
+ async function getChange(config2, changeId, include) {
11160
+ const params = include ? { include } : {};
11161
+ const data = await fsGet(config2, `changes/${changeId}`, params);
11162
+ return data.change;
11163
+ }
11164
+ async function createChange(config2, payload) {
11165
+ const data = await fsPost(
11166
+ config2,
11167
+ "changes",
11168
+ withChangePlan(payload)
11169
+ );
11170
+ return data.change;
11171
+ }
11172
+ async function updateChange(config2, changeId, payload) {
11173
+ const data = await fsPut(
11174
+ config2,
11175
+ `changes/${changeId}`,
11176
+ withChangePlan(payload)
11177
+ );
11178
+ return data.change;
11179
+ }
11180
+ async function associateTicketsToChange(config2, changeDisplayId, ticketIds, associationType = "change_initiated_by_ticket") {
11181
+ return Promise.all(
11182
+ ticketIds.map(
11183
+ (ticketId) => associateOneTicket(config2, ticketId, changeDisplayId, associationType)
11184
+ )
11185
+ );
11186
+ }
11187
+ function isBlankDescriptionError(error2) {
11188
+ const message = error2 instanceof Error ? error2.message : String(error2);
11189
+ return message.includes("400") && message.includes('"field":"description"') && message.toLowerCase().includes("blank");
11190
+ }
11191
+ async function associateOneTicket(config2, ticketId, changeDisplayId, associationType) {
11192
+ const association = { [associationType]: { display_id: changeDisplayId } };
11193
+ try {
11194
+ return await updateTicket(config2, ticketId, association);
11195
+ } catch (error2) {
11196
+ if (!isBlankDescriptionError(error2)) throw error2;
11197
+ const existing = await getTicket(config2, ticketId, "");
11198
+ const currentDescription = existing.description_text?.trim() || stripHtml(existing.description ?? "");
11199
+ if (currentDescription) throw error2;
11200
+ return updateTicket(config2, ticketId, {
11201
+ ...association,
11202
+ description: BLANK_DESCRIPTION_PLACEHOLDER
11203
+ });
11204
+ }
11205
+ }
11206
+ async function addChangeNote(config2, changeId, payload) {
11207
+ const data = await fsPost(
11208
+ config2,
11209
+ `changes/${changeId}/notes`,
11210
+ payload
11211
+ );
11212
+ return data.note;
11213
+ }
11130
11214
  async function getMe(config2) {
11131
11215
  const data = await fsGet(config2, "agents/me");
11132
11216
  return data.agent;
@@ -11139,10 +11223,11 @@ async function listAgents(config2, options = {}) {
11139
11223
  const data = await fsGet(config2, "agents", params);
11140
11224
  return data.agents ?? [];
11141
11225
  }
11142
- var DEFAULT_STATUS_MAP, PRIORITY_MAP;
11226
+ var BLANK_DESCRIPTION_PLACEHOLDER, DEFAULT_STATUS_MAP, PRIORITY_MAP, CHANGE_PRIORITY_MAP, CHANGE_STATUS_MAP, CHANGE_TYPE_MAP, CHANGE_IMPACT_MAP, CHANGE_RISK_MAP;
11143
11227
  var init_freshservice_client = __esm({
11144
11228
  "src/freshservice-client.ts"() {
11145
11229
  "use strict";
11230
+ BLANK_DESCRIPTION_PLACEHOLDER = ".";
11146
11231
  DEFAULT_STATUS_MAP = {
11147
11232
  2: "Open",
11148
11233
  3: "Pending",
@@ -11155,6 +11240,32 @@ var init_freshservice_client = __esm({
11155
11240
  3: "High",
11156
11241
  4: "Urgent"
11157
11242
  };
11243
+ CHANGE_PRIORITY_MAP = PRIORITY_MAP;
11244
+ CHANGE_STATUS_MAP = {
11245
+ 1: "Open",
11246
+ 2: "Planning",
11247
+ 3: "Awaiting Approval",
11248
+ 4: "Pending Release",
11249
+ 5: "Pending Review",
11250
+ 6: "Closed"
11251
+ };
11252
+ CHANGE_TYPE_MAP = {
11253
+ 1: "Minor",
11254
+ 2: "Standard",
11255
+ 3: "Major",
11256
+ 4: "Emergency"
11257
+ };
11258
+ CHANGE_IMPACT_MAP = {
11259
+ 1: "Low",
11260
+ 2: "Medium",
11261
+ 3: "High"
11262
+ };
11263
+ CHANGE_RISK_MAP = {
11264
+ 1: "Low",
11265
+ 2: "Medium",
11266
+ 3: "High",
11267
+ 4: "Very High"
11268
+ };
11158
11269
  }
11159
11270
  });
11160
11271
 
@@ -11279,6 +11390,94 @@ var init_tickets_readonly = __esm({
11279
11390
  }
11280
11391
  });
11281
11392
 
11393
+ // src/tools/changes-readonly.ts
11394
+ var changes_readonly_exports = {};
11395
+ __export(changes_readonly_exports, {
11396
+ registerReadOnlyChangeTools: () => registerReadOnlyChangeTools
11397
+ });
11398
+ function label2(value, values) {
11399
+ return values[value] ?? String(value);
11400
+ }
11401
+ function normalizeChange2(change) {
11402
+ return {
11403
+ id: change.id,
11404
+ subject: change.subject,
11405
+ status: label2(change.status, CHANGE_STATUS_MAP),
11406
+ priority: label2(change.priority, CHANGE_PRIORITY_MAP),
11407
+ impact: label2(change.impact, CHANGE_IMPACT_MAP),
11408
+ risk: label2(change.risk, CHANGE_RISK_MAP),
11409
+ change_type: label2(change.change_type, CHANGE_TYPE_MAP),
11410
+ requester_id: change.requester_id,
11411
+ agent_id: change.agent_id ?? null,
11412
+ group_id: change.group_id ?? null,
11413
+ planned_start_date: change.planned_start_date ?? null,
11414
+ planned_end_date: change.planned_end_date ?? null,
11415
+ created_at: change.created_at,
11416
+ updated_at: change.updated_at,
11417
+ url: `https://${process.env.FRESHSERVICE_DOMAIN}/helpdesk/changes/${change.id}`
11418
+ };
11419
+ }
11420
+ function registerReadOnlyChangeTools(server, config2) {
11421
+ server.registerTool(
11422
+ "list_changes",
11423
+ {
11424
+ title: "List Changes",
11425
+ description: "List Freshservice Changes with filtering, sorting and pagination.",
11426
+ inputSchema: external_exports.object({
11427
+ query: external_exports.string().max(512).optional(),
11428
+ view: external_exports.string().optional(),
11429
+ updated_since: external_exports.string().optional(),
11430
+ workspace_id: external_exports.number().int().nonnegative().optional(),
11431
+ order_by: external_exports.string().optional(),
11432
+ order_type: external_exports.enum(["asc", "desc"]).optional(),
11433
+ limit: external_exports.number().int().min(1).max(500).optional()
11434
+ }).refine(({ query, view }) => !(query && view), {
11435
+ message: "query and view cannot be used together"
11436
+ })
11437
+ },
11438
+ async (args) => {
11439
+ const changes = await listChanges(config2, args);
11440
+ if (changes.length === 0) {
11441
+ return { content: [{ type: "text", text: "No changes found." }] };
11442
+ }
11443
+ const normalized = changes.map(normalizeChange2);
11444
+ return {
11445
+ content: [{
11446
+ type: "text",
11447
+ text: JSON.stringify({ count: normalized.length, changes: normalized }, null, 2)
11448
+ }]
11449
+ };
11450
+ }
11451
+ );
11452
+ server.registerTool(
11453
+ "get_change",
11454
+ {
11455
+ title: "Get Change",
11456
+ description: "Retrieve full details of a Freshservice Change by ID.",
11457
+ inputSchema: external_exports.object({
11458
+ change_id: external_exports.number().int().positive(),
11459
+ include: external_exports.string().optional()
11460
+ })
11461
+ },
11462
+ async ({ change_id, include }) => {
11463
+ const change = await getChange(config2, change_id, include);
11464
+ const result = {
11465
+ ...normalizeChange2(change),
11466
+ description: change.description_text?.trim() ?? stripHtml(change.description ?? ""),
11467
+ change_plan: change.planning_fields?.change_plan ? stripHtml(change.planning_fields.change_plan) : null
11468
+ };
11469
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
11470
+ }
11471
+ );
11472
+ }
11473
+ var init_changes_readonly = __esm({
11474
+ "src/tools/changes-readonly.ts"() {
11475
+ "use strict";
11476
+ init_esm();
11477
+ init_freshservice_client();
11478
+ }
11479
+ });
11480
+
11282
11481
  // node_modules/@modelcontextprotocol/sdk/dist/esm/server/zod-compat.js
11283
11482
  init_esm();
11284
11483
 
@@ -21512,6 +21711,225 @@ ${JSON.stringify(result, null, 2)}`
21512
21711
  );
21513
21712
  }
21514
21713
 
21714
+ // src/tools/changes.ts
21715
+ init_esm();
21716
+ init_freshservice_client();
21717
+ function label(value, values) {
21718
+ return values[value] ?? String(value);
21719
+ }
21720
+ function normalizeChange(change) {
21721
+ return {
21722
+ id: change.id,
21723
+ subject: change.subject,
21724
+ status: label(change.status, CHANGE_STATUS_MAP),
21725
+ priority: label(change.priority, CHANGE_PRIORITY_MAP),
21726
+ impact: label(change.impact, CHANGE_IMPACT_MAP),
21727
+ risk: label(change.risk, CHANGE_RISK_MAP),
21728
+ change_type: label(change.change_type, CHANGE_TYPE_MAP),
21729
+ requester_id: change.requester_id,
21730
+ agent_id: change.agent_id ?? null,
21731
+ group_id: change.group_id ?? null,
21732
+ planned_start_date: change.planned_start_date ?? null,
21733
+ planned_end_date: change.planned_end_date ?? null,
21734
+ created_at: change.created_at,
21735
+ updated_at: change.updated_at,
21736
+ url: `https://${process.env.FRESHSERVICE_DOMAIN}/helpdesk/changes/${change.id}`
21737
+ };
21738
+ }
21739
+ var listChangesSchema = external_exports.object({
21740
+ query: external_exports.string().max(512).optional().describe('Freshservice query, e.g. "priority:4 OR priority:3"'),
21741
+ view: external_exports.string().optional().describe("Default view name or custom Change view ID; cannot be combined with query"),
21742
+ updated_since: external_exports.string().optional().describe("Return Changes updated since this UTC date or timestamp"),
21743
+ workspace_id: external_exports.number().int().nonnegative().optional().describe("Workspace ID; 0 requests Changes across all accessible workspaces"),
21744
+ order_by: external_exports.string().optional().describe("Field to sort by, e.g. priority or updated_at"),
21745
+ order_type: external_exports.enum(["asc", "desc"]).optional().describe("Sort direction (default: desc)"),
21746
+ limit: external_exports.number().int().min(1).max(500).optional().describe("Maximum results (default: 100)")
21747
+ }).refine(({ query, view }) => !(query && view), {
21748
+ message: "query and view cannot be used together"
21749
+ });
21750
+ var changeFields = {
21751
+ subject: external_exports.string().min(1).optional().describe("Change subject"),
21752
+ description: external_exports.string().min(1).optional().describe(`Change description. ${HTML_FIELD_HINT}`),
21753
+ requester_id: external_exports.number().int().positive().optional().describe("Initiating requester ID"),
21754
+ priority: external_exports.enum(["1", "2", "3", "4"]).optional().describe("1=Low, 2=Medium, 3=High, 4=Urgent"),
21755
+ status: external_exports.enum(["1", "2", "3", "4", "5", "6"]).optional().describe(
21756
+ "1=Open, 2=Planning, 3=Awaiting Approval, 4=Pending Release, 5=Pending Review, 6=Closed"
21757
+ ),
21758
+ impact: external_exports.enum(["1", "2", "3"]).optional().describe("1=Low, 2=Medium, 3=High"),
21759
+ risk: external_exports.enum(["1", "2", "3", "4"]).optional().describe("1=Low, 2=Medium, 3=High, 4=Very High"),
21760
+ change_type: external_exports.enum(["1", "2", "3", "4"]).optional().describe(
21761
+ "1=Minor, 2=Standard, 3=Major, 4=Emergency"
21762
+ ),
21763
+ planned_start_date: external_exports.string().optional().describe("Planned start as an ISO 8601 UTC timestamp"),
21764
+ planned_end_date: external_exports.string().optional().describe("Planned end as an ISO 8601 UTC timestamp"),
21765
+ change_plan: external_exports.string().optional().describe(`Rollout plan. ${HTML_FIELD_HINT}`),
21766
+ group_id: external_exports.number().int().positive().optional().describe("Assigned agent group ID"),
21767
+ agent_id: external_exports.number().int().positive().optional().describe("Assigned agent ID")
21768
+ };
21769
+ function payloadFromArgs(args) {
21770
+ const payload = {};
21771
+ for (const [key, value] of Object.entries(args)) {
21772
+ if (value === void 0 || key === "change_id") continue;
21773
+ payload[key] = ["priority", "status", "impact", "risk", "change_type"].includes(key) ? Number(value) : value;
21774
+ }
21775
+ return payload;
21776
+ }
21777
+ function registerChangeTools(server, config2) {
21778
+ server.registerTool(
21779
+ "list_changes",
21780
+ {
21781
+ title: "List Changes",
21782
+ description: "List Freshservice Changes with filtering, sorting and pagination.",
21783
+ inputSchema: listChangesSchema
21784
+ },
21785
+ async (args) => {
21786
+ const changes = await listChanges(config2, args);
21787
+ if (changes.length === 0) {
21788
+ return { content: [{ type: "text", text: "No changes found." }] };
21789
+ }
21790
+ const normalized = changes.map(normalizeChange);
21791
+ return {
21792
+ content: [{
21793
+ type: "text",
21794
+ text: JSON.stringify({ count: normalized.length, changes: normalized }, null, 2)
21795
+ }]
21796
+ };
21797
+ }
21798
+ );
21799
+ server.registerTool(
21800
+ "get_change",
21801
+ {
21802
+ title: "Get Change",
21803
+ description: "Retrieve full details of a Freshservice Change by ID.",
21804
+ inputSchema: external_exports.object({
21805
+ change_id: external_exports.number().int().positive().describe("Freshservice Change ID"),
21806
+ include: external_exports.string().optional().describe("Extra details to embed, e.g. stats")
21807
+ })
21808
+ },
21809
+ async ({ change_id, include }) => {
21810
+ const change = await getChange(config2, change_id, include);
21811
+ const result = {
21812
+ ...normalizeChange(change),
21813
+ description: change.description_text?.trim() ?? stripHtml(change.description ?? ""),
21814
+ change_plan: change.planning_fields?.change_plan ? stripHtml(change.planning_fields.change_plan) : null
21815
+ };
21816
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
21817
+ }
21818
+ );
21819
+ server.registerTool(
21820
+ "create_change",
21821
+ {
21822
+ title: "Create Change",
21823
+ description: "Create a Freshservice Change with all standard mandatory fields.",
21824
+ inputSchema: external_exports.object({
21825
+ subject: changeFields.subject.unwrap(),
21826
+ description: changeFields.description.unwrap(),
21827
+ requester_id: changeFields.requester_id.unwrap(),
21828
+ priority: changeFields.priority.unwrap(),
21829
+ status: changeFields.status.unwrap(),
21830
+ impact: changeFields.impact.unwrap(),
21831
+ risk: changeFields.risk.unwrap(),
21832
+ change_type: changeFields.change_type.unwrap(),
21833
+ planned_start_date: changeFields.planned_start_date.unwrap(),
21834
+ planned_end_date: changeFields.planned_end_date.unwrap(),
21835
+ change_plan: changeFields.change_plan,
21836
+ group_id: changeFields.group_id,
21837
+ agent_id: changeFields.agent_id
21838
+ })
21839
+ },
21840
+ async (args) => {
21841
+ const change = await createChange(config2, payloadFromArgs(args));
21842
+ return {
21843
+ content: [{
21844
+ type: "text",
21845
+ text: `Change #${change.id} created.
21846
+
21847
+ ${JSON.stringify(normalizeChange(change), null, 2)}`
21848
+ }]
21849
+ };
21850
+ }
21851
+ );
21852
+ server.registerTool(
21853
+ "update_change",
21854
+ {
21855
+ title: "Update Change",
21856
+ description: "Update provided fields of an existing Freshservice Change.",
21857
+ inputSchema: external_exports.object({
21858
+ change_id: external_exports.number().int().positive().describe("Freshservice Change ID"),
21859
+ ...changeFields
21860
+ })
21861
+ },
21862
+ async (args) => {
21863
+ const change = await updateChange(config2, args.change_id, payloadFromArgs(args));
21864
+ return {
21865
+ content: [{
21866
+ type: "text",
21867
+ text: `Change #${args.change_id} updated.
21868
+
21869
+ ${JSON.stringify(normalizeChange(change), null, 2)}`
21870
+ }]
21871
+ };
21872
+ }
21873
+ );
21874
+ server.registerTool(
21875
+ "associate_tickets_to_change",
21876
+ {
21877
+ title: "Associate Tickets to Change",
21878
+ description: "Associate existing tickets with a Change by updating each ticket using Freshservice's documented Change association object. A ticket whose description is empty on an instance where description is mandatory is retried once with a minimal placeholder description so the association still lands; existing descriptions are never overwritten.",
21879
+ inputSchema: external_exports.object({
21880
+ change_id: external_exports.number().int().positive().describe(
21881
+ "Change display ID (Freshservice requires display_id in the association body)"
21882
+ ),
21883
+ ticket_ids: external_exports.array(external_exports.number().int().positive()).min(1).max(100).describe(
21884
+ "Freshservice ticket IDs to associate"
21885
+ ),
21886
+ association_type: external_exports.enum(["change_initiated_by_ticket", "change_initiating_ticket"]).optional().describe(
21887
+ "Relationship direction. Default: change_initiated_by_ticket (the ticket initiated the Change); use change_initiating_ticket when the Change initiated the ticket."
21888
+ )
21889
+ })
21890
+ },
21891
+ async ({ change_id, ticket_ids, association_type }) => {
21892
+ const tickets = await associateTicketsToChange(
21893
+ config2,
21894
+ change_id,
21895
+ ticket_ids,
21896
+ association_type
21897
+ );
21898
+ return {
21899
+ content: [{
21900
+ type: "text",
21901
+ text: JSON.stringify({
21902
+ change_id,
21903
+ association_type: association_type ?? "change_initiated_by_ticket",
21904
+ associated_ticket_ids: tickets.map((ticket) => ticket.id)
21905
+ }, null, 2)
21906
+ }]
21907
+ };
21908
+ }
21909
+ );
21910
+ server.registerTool(
21911
+ "add_change_note",
21912
+ {
21913
+ title: "Add Change Note",
21914
+ description: "Add a note to a Freshservice Change.",
21915
+ inputSchema: external_exports.object({
21916
+ change_id: external_exports.number().int().positive().describe("Freshservice Change ID"),
21917
+ body: external_exports.string().min(1).describe(`Note body. ${HTML_FIELD_HINT}`),
21918
+ notify_emails: external_exports.array(external_exports.string().email()).optional().describe("Email addresses to notify")
21919
+ })
21920
+ },
21921
+ async ({ change_id, body, notify_emails }) => {
21922
+ const note = await addChangeNote(config2, change_id, { body, notify_emails });
21923
+ return {
21924
+ content: [{
21925
+ type: "text",
21926
+ text: `Note added to Change #${change_id} (note id: ${note.id}).`
21927
+ }]
21928
+ };
21929
+ }
21930
+ );
21931
+ }
21932
+
21515
21933
  // src/tools/agents.ts
21516
21934
  init_esm();
21517
21935
  init_freshservice_client();
@@ -21587,15 +22005,18 @@ async function main() {
21587
22005
  const readonly2 = process.env.FRESHSERVICE_READONLY === "true";
21588
22006
  const server = new McpServer({
21589
22007
  name: "freshservice-mcp",
21590
- version: "0.1.0"
22008
+ version: "0.2.0"
21591
22009
  });
21592
22010
  registerAgentTools(server, config2);
21593
22011
  if (readonly2) {
21594
22012
  const { registerReadOnlyTicketTools: registerReadOnlyTicketTools2 } = await Promise.resolve().then(() => (init_tickets_readonly(), tickets_readonly_exports));
22013
+ const { registerReadOnlyChangeTools: registerReadOnlyChangeTools2 } = await Promise.resolve().then(() => (init_changes_readonly(), changes_readonly_exports));
21595
22014
  registerReadOnlyTicketTools2(server, config2);
22015
+ registerReadOnlyChangeTools2(server, config2);
21596
22016
  console.error("Freshservice MCP Server running in read-only mode (stdio)");
21597
22017
  } else {
21598
22018
  registerTicketTools(server, config2);
22019
+ registerChangeTools(server, config2);
21599
22020
  console.error("Freshservice MCP Server running on stdio");
21600
22021
  }
21601
22022
  const transport = new StdioServerTransport();
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "freshservice-mcp",
3
- "version": "0.1.2",
4
- "description": "MCP server for Freshservice – manage tickets, agents and conversations from any MCP-compatible AI client",
3
+ "version": "0.2.1",
4
+ "description": "MCP server for Freshservice – manage tickets, changes, agents and conversations from any MCP-compatible AI client",
5
5
  "keywords": [
6
6
  "mcp",
7
7
  "freshservice",