mcp-connectwise-psa 0.1.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.
@@ -0,0 +1,175 @@
1
+ /**
2
+ * HTTP transport: Express app exposing
3
+ *
4
+ * POST/GET/DELETE /mcp MCP streamable-http endpoint (auth + RBAC)
5
+ * GET /health liveness probe
6
+ *
7
+ * Authentication model:
8
+ * - Role tokens (Authorization: Bearer …) map to viewer/editor/admin and gate
9
+ * which tools a session gets, using the server-wide ConnectWise keys.
10
+ * - Bring-your-own-keys: x-cw-public-key + x-cw-private-key headers bind the
11
+ * session to that member's API keys with the FULL tool surface — the
12
+ * member's own ConnectWise security role is the access control, and writes
13
+ * (notes, time entries) are attributed to that member. Optional
14
+ * x-cw-member-id header names the member for "my tickets"/"my time".
15
+ * CLIENT_CW_KEYS controls the policy (with-token default / open / disabled).
16
+ * - A session id never carries privilege: every request re-authenticates and
17
+ * must present the same principal (role + label + key hash) the session was
18
+ * created with.
19
+ */
20
+ import { createHash, randomUUID } from "node:crypto";
21
+ import express from "express";
22
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
23
+ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
24
+ import { authenticateToken, loadTokenEntries } from "../auth/tokens.js";
25
+ import { createServer, SERVER_NAME, SERVER_VERSION } from "../server.js";
26
+ const sha256 = (value) => createHash("sha256").update(value).digest("hex");
27
+ function rpcError(res, status, code, message) {
28
+ res.status(status).json({ jsonrpc: "2.0", error: { code, message }, id: null });
29
+ }
30
+ const unauthorized = (message) => ({
31
+ ok: false,
32
+ status: 401,
33
+ code: -32001,
34
+ message,
35
+ });
36
+ function headerValue(req, name) {
37
+ const value = req.headers[name];
38
+ return Array.isArray(value) ? value[0] : value;
39
+ }
40
+ /** Resolve the principal for a request. Exported for tests. */
41
+ export function resolveAuth(req, config) {
42
+ const entries = loadTokenEntries();
43
+ const token = authenticateToken(req.headers.authorization, entries);
44
+ const clientPublic = headerValue(req, "x-cw-public-key");
45
+ const clientPrivate = headerValue(req, "x-cw-private-key");
46
+ const clientMember = headerValue(req, "x-cw-member-id");
47
+ if (clientPublic || clientPrivate) {
48
+ if (config.clientKeyMode === "disabled") {
49
+ return unauthorized("Client-supplied ConnectWise keys are disabled on this server (CLIENT_CW_KEYS=disabled).");
50
+ }
51
+ if (!clientPublic || !clientPrivate) {
52
+ return unauthorized("Both x-cw-public-key and x-cw-private-key headers are required.");
53
+ }
54
+ const keyHash = sha256(`${clientPublic}:${clientPrivate}`);
55
+ const byokLabel = `byok:${keyHash.slice(0, 8)}`;
56
+ if (config.clientKeyMode === "with-token" && entries.length > 0 && !token) {
57
+ return unauthorized("A valid bearer token is required alongside your ConnectWise keys (CLIENT_CW_KEYS=with-token).");
58
+ }
59
+ const label = token ? `${token.label}+${byokLabel}` : byokLabel;
60
+ // BYOK sessions get the full tool surface; the member's own ConnectWise
61
+ // security role is the effective access control.
62
+ return {
63
+ ok: true,
64
+ role: "admin",
65
+ label,
66
+ credentials: {
67
+ publicKey: clientPublic,
68
+ privateKey: clientPrivate,
69
+ memberIdentifier: clientMember,
70
+ },
71
+ keyHash,
72
+ };
73
+ }
74
+ const serverCredentials = config.publicKey && config.privateKey
75
+ ? {
76
+ publicKey: config.publicKey,
77
+ privateKey: config.privateKey,
78
+ memberIdentifier: config.memberIdentifier,
79
+ }
80
+ : null;
81
+ if (entries.length === 0) {
82
+ if (!serverCredentials) {
83
+ return unauthorized("No ConnectWise keys available — set CW_PUBLIC_KEY/CW_PRIVATE_KEY on the server or send x-cw-public-key/x-cw-private-key.");
84
+ }
85
+ return { ok: true, role: "admin", label: "dev-unauthenticated", credentials: serverCredentials, keyHash: null };
86
+ }
87
+ if (!token) {
88
+ return unauthorized("Unauthorized: invalid or missing bearer token.");
89
+ }
90
+ if (!serverCredentials) {
91
+ return unauthorized("This server has no shared ConnectWise keys — supply your own via the x-cw-public-key/x-cw-private-key headers.");
92
+ }
93
+ return { ok: true, role: token.role, label: token.label, credentials: serverCredentials, keyHash: null };
94
+ }
95
+ function principalMatches(session, auth) {
96
+ return (session.role === auth.role && session.label === auth.label && session.keyHash === auth.keyHash);
97
+ }
98
+ export function createApp(config) {
99
+ const app = express();
100
+ app.use(express.json({ limit: "2mb" }));
101
+ const sessions = new Map();
102
+ app.get("/health", (_req, res) => {
103
+ res.json({ status: "ok", name: SERVER_NAME, version: SERVER_VERSION });
104
+ });
105
+ app.post("/mcp", (req, res) => {
106
+ void (async () => {
107
+ const auth = resolveAuth(req, config);
108
+ if (!auth.ok)
109
+ return rpcError(res, auth.status, auth.code, auth.message);
110
+ const sessionId = headerValue(req, "mcp-session-id");
111
+ if (sessionId) {
112
+ const session = sessions.get(sessionId);
113
+ if (!session)
114
+ return rpcError(res, 404, -32000, "Session not found");
115
+ if (!principalMatches(session, auth)) {
116
+ return rpcError(res, 403, -32003, "Forbidden: credentials do not match this session");
117
+ }
118
+ await session.transport.handleRequest(req, res, req.body);
119
+ return;
120
+ }
121
+ if (!isInitializeRequest(req.body)) {
122
+ return rpcError(res, 400, -32600, "Bad request: expected an initialize request");
123
+ }
124
+ const transport = new StreamableHTTPServerTransport({
125
+ sessionIdGenerator: () => randomUUID(),
126
+ onsessioninitialized: (newSessionId) => {
127
+ sessions.set(newSessionId, {
128
+ transport,
129
+ role: auth.role,
130
+ label: auth.label,
131
+ keyHash: auth.keyHash,
132
+ });
133
+ console.error(`[rbac] session ${newSessionId} created for ${auth.label} (${auth.role})`);
134
+ },
135
+ });
136
+ transport.onclose = () => {
137
+ if (transport.sessionId)
138
+ sessions.delete(transport.sessionId);
139
+ };
140
+ const server = createServer(config, {
141
+ role: auth.role,
142
+ label: auth.label,
143
+ credentials: auth.credentials,
144
+ });
145
+ await server.connect(transport);
146
+ await transport.handleRequest(req, res, req.body);
147
+ })().catch((err) => {
148
+ console.error(`[http] POST /mcp failed: ${String(err)}`);
149
+ if (!res.headersSent)
150
+ rpcError(res, 500, -32603, "Internal server error");
151
+ });
152
+ });
153
+ const handleSessionRequest = (req, res) => {
154
+ void (async () => {
155
+ const auth = resolveAuth(req, config);
156
+ if (!auth.ok)
157
+ return rpcError(res, auth.status, auth.code, auth.message);
158
+ const sessionId = headerValue(req, "mcp-session-id");
159
+ const session = sessionId ? sessions.get(sessionId) : undefined;
160
+ if (!session)
161
+ return rpcError(res, 404, -32000, "Session not found");
162
+ if (!principalMatches(session, auth)) {
163
+ return rpcError(res, 403, -32003, "Forbidden: credentials do not match this session");
164
+ }
165
+ await session.transport.handleRequest(req, res);
166
+ })().catch((err) => {
167
+ console.error(`[http] ${req.method} /mcp failed: ${String(err)}`);
168
+ if (!res.headersSent)
169
+ rpcError(res, 500, -32603, "Internal server error");
170
+ });
171
+ };
172
+ app.get("/mcp", handleSessionRequest);
173
+ app.delete("/mcp", handleSessionRequest);
174
+ return app;
175
+ }
package/dist/index.js ADDED
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env node
2
+ /** CLI entry point — runs the MCP server over stdio (default) or HTTP. */
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { ConfigError, loadConfig } from "./config.js";
5
+ import { loadTokenEntries } from "./auth/tokens.js";
6
+ import { createServer, SERVER_NAME, SERVER_VERSION } from "./server.js";
7
+ import { createApp } from "./http/app.js";
8
+ const USAGE = `${SERVER_NAME} v${SERVER_VERSION}
9
+
10
+ Usage: mcp-connectwise-psa [options]
11
+
12
+ Options:
13
+ --transport stdio|http Transport (default: stdio; env TRANSPORT)
14
+ --port <n> HTTP port (default: 3000; env PORT)
15
+ --site <host> ConnectWise host (env CW_SITE)
16
+ --help Show this help
17
+
18
+ Environment:
19
+ CW_SITE ConnectWise host (e.g. na.myconnectwise.net)
20
+ CW_COMPANY_ID Login company id
21
+ CW_CLIENT_ID Integration clientId (developer.connectwise.com)
22
+ CW_PUBLIC_KEY Server-wide API member public key (optional with BYOK)
23
+ CW_PRIVATE_KEY Server-wide API member private key
24
+ CW_MEMBER_IDENTIFIER Member the server-wide keys belong to (my-tickets/my-time)
25
+ MCP_TOKENS_VIEWER label:token[,label:token…] — read-only access (HTTP)
26
+ MCP_TOKENS_EDITOR label:token[,…] — read + tickets/notes/time writes
27
+ MCP_TOKENS_ADMIN label:token[,…] — reserved for future destructive tools
28
+ CLIENT_CW_KEYS disabled|with-token|open — client-supplied CW keys (default: with-token)
29
+ `;
30
+ function logStartupSummary(config) {
31
+ const entries = loadTokenEntries();
32
+ if (entries.length === 0) {
33
+ console.error("[auth] WARNING: no MCP_TOKENS_* configured — all HTTP requests are accepted with FULL access. " +
34
+ "Set MCP_TOKENS_VIEWER / MCP_TOKENS_EDITOR / MCP_TOKENS_ADMIN in production.");
35
+ }
36
+ else {
37
+ console.error(`[auth] ${entries.length} token(s) configured: ${entries.map((e) => `${e.label}(${e.role})`).join(", ")}`);
38
+ }
39
+ console.error(`[auth] Client-supplied ConnectWise keys: ${config.clientKeyMode}`);
40
+ console.error(config.publicKey
41
+ ? `[cw] Server-wide keys configured${config.memberIdentifier ? ` (member: ${config.memberIdentifier})` : " (no CW_MEMBER_IDENTIFIER — my-tickets/my-time unavailable for token-only sessions)"}`
42
+ : "[cw] No server-wide keys — sessions must bring their own via x-cw-public-key/x-cw-private-key.");
43
+ }
44
+ async function runStdio(config) {
45
+ const server = createServer(config, {
46
+ role: "admin",
47
+ label: "stdio",
48
+ credentials: {
49
+ publicKey: config.publicKey,
50
+ privateKey: config.privateKey,
51
+ memberIdentifier: config.memberIdentifier,
52
+ },
53
+ });
54
+ await server.connect(new StdioServerTransport());
55
+ console.error(`${SERVER_NAME} v${SERVER_VERSION} running on stdio (${config.site})`);
56
+ }
57
+ async function runHttp(config) {
58
+ logStartupSummary(config);
59
+ const app = createApp(config);
60
+ await new Promise((resolve) => {
61
+ app.listen(config.port, "0.0.0.0", () => resolve());
62
+ });
63
+ console.error(`${SERVER_NAME} v${SERVER_VERSION} listening on :${config.port} (${config.site})`);
64
+ }
65
+ async function main() {
66
+ const argv = process.argv.slice(2);
67
+ if (argv.includes("--help") || argv.includes("-h")) {
68
+ console.log(USAGE);
69
+ return;
70
+ }
71
+ let config;
72
+ try {
73
+ config = loadConfig(argv);
74
+ }
75
+ catch (err) {
76
+ if (err instanceof ConfigError) {
77
+ console.error(`Configuration error: ${err.message}\n`);
78
+ console.error(USAGE);
79
+ process.exit(1);
80
+ }
81
+ throw err;
82
+ }
83
+ if (config.transport === "http")
84
+ await runHttp(config);
85
+ else
86
+ await runStdio(config);
87
+ }
88
+ main().catch((err) => {
89
+ console.error(`Fatal: ${String(err)}`);
90
+ process.exit(1);
91
+ });
package/dist/server.js ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Builds an McpServer for one session. A session is defined by:
3
+ * - the ConnectWise credentials it uses (server-wide keys or client-supplied)
4
+ * - the role that gates which tools are registered
5
+ */
6
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
7
+ import { createRequire } from "node:module";
8
+ import { ToolRegistrar } from "./auth/roles.js";
9
+ import { CWClient } from "./cw/client.js";
10
+ import { registerTicketTools } from "./tools/tickets.js";
11
+ import { registerTimeTools } from "./tools/time.js";
12
+ import { registerCompanyTools } from "./tools/companies.js";
13
+ import { registerConfigurationTools } from "./tools/configurations.js";
14
+ const require = createRequire(import.meta.url);
15
+ const pkg = require("../package.json");
16
+ export const SERVER_NAME = pkg.name;
17
+ export const SERVER_VERSION = pkg.version;
18
+ const INSTRUCTIONS = `# ConnectWise PSA MCP server
19
+
20
+ ## Finding things
21
+ - Typical flow: cw_search_companies → cw_search_tickets / cw_list_configurations → cw_get_ticket.
22
+ - cw_my_tickets and cw_list_my_time act as the member whose API keys this session uses.
23
+ - Ticket searches default to OPEN tickets; pass open_only:false for closed ones.
24
+ - Status/board/priority filters use exact names; summary and name filters match substrings.
25
+
26
+ ## Working tickets
27
+ - cw_add_ticket_note: discussion notes are CUSTOMER-VISIBLE; set internal:true for internal analysis.
28
+ - cw_update_ticket status names must exist on the ticket's board.
29
+ - cw_create_time_entry needs time_start plus time_end or hours; notes describe the work done.
30
+ - Writes are attributed to the API keys' member — that's the point: use your own keys.
31
+
32
+ ## Notes
33
+ - Lists are paginated; ask for more pages rather than huge page sizes.
34
+ - Some tools may be unavailable depending on this session's access level.`;
35
+ export function createServer(config, session) {
36
+ const client = new CWClient({
37
+ site: config.site,
38
+ companyId: config.companyId,
39
+ clientId: config.clientId,
40
+ credentials: session.credentials,
41
+ });
42
+ const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION }, { instructions: INSTRUCTIONS });
43
+ const reg = new ToolRegistrar(server, session.role);
44
+ registerTicketTools(reg, client);
45
+ registerTimeTools(reg, client);
46
+ registerCompanyTools(reg, client);
47
+ registerConfigurationTools(reg, client);
48
+ return server;
49
+ }
@@ -0,0 +1,109 @@
1
+ /** Company and contact lookup tools (read-only). */
2
+ import { z } from "zod";
3
+ import { allOf, q } from "../cw/client.js";
4
+ import { clip, failure, json, pageFooter, pageNumberField, pageSizeField, responseFormatField, text, } from "./shared.js";
5
+ const COMPANY_FIELDS = "id,identifier,name,status/name,city,state,phoneNumber,website";
6
+ const CONTACT_FIELDS = "id,firstName,lastName,title,company/name,company/id,defaultPhoneNbr,inactiveFlag";
7
+ export function registerCompanyTools(reg, client) {
8
+ reg.register({
9
+ name: "cw_search_companies",
10
+ title: "Search ConnectWise Companies",
11
+ description: "Search companies by name (substring). Use this to find the company ID needed by ticket and configuration tools.",
12
+ inputSchema: {
13
+ name_contains: z.string().optional().describe("Text the company name contains"),
14
+ active_only: z.boolean().default(true).describe('Only status "Active" companies (default true)'),
15
+ page_number: pageNumberField,
16
+ page_size: pageSizeField,
17
+ response_format: responseFormatField,
18
+ },
19
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
20
+ }, async (args) => {
21
+ try {
22
+ const page = await client.getList("/company/companies", {
23
+ conditions: allOf("deletedFlag=false", args.active_only && `status/name=${q("Active")}`, args.name_contains && `name contains ${q(args.name_contains)}`),
24
+ orderBy: "name asc",
25
+ fields: COMPANY_FIELDS,
26
+ page: args.page_number,
27
+ pageSize: args.page_size,
28
+ });
29
+ if (page.items.length === 0)
30
+ return text("No companies found.");
31
+ if (args.response_format === "json")
32
+ return text(clip(json(page)));
33
+ const lines = [`# Companies (${page.items.length} on this page)`, ""];
34
+ for (const c of page.items) {
35
+ lines.push(`- **${c.name}** (ID: ${c.id})${c.status?.name ? ` — ${c.status.name}` : ""}${c.city ? ` — ${c.city}, ${c.state ?? ""}` : ""}`);
36
+ }
37
+ lines.push("", pageFooter(page.page, page.hasMore));
38
+ return text(clip(lines.join("\n")));
39
+ }
40
+ catch (error) {
41
+ return failure(error);
42
+ }
43
+ });
44
+ reg.register({
45
+ name: "cw_get_company",
46
+ title: "Get ConnectWise Company",
47
+ description: "Get one company by ID.",
48
+ inputSchema: {
49
+ company_id: z.number().int().positive().describe("The company ID"),
50
+ response_format: responseFormatField,
51
+ },
52
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
53
+ }, async (args) => {
54
+ try {
55
+ const company = await client.getOne(`/company/companies/${args.company_id}`, COMPANY_FIELDS + ",addressLine1,territory/name,market/name");
56
+ if (args.response_format === "json")
57
+ return text(json(company));
58
+ const lines = [
59
+ `# ${company.name} (ID: ${company.id})`,
60
+ `- **Status**: ${company.status?.name ?? "?"}`,
61
+ `- **Address**: ${[company.addressLine1, company.city, company.state].filter(Boolean).join(", ") || "—"}`,
62
+ `- **Phone**: ${company.phoneNumber ?? "—"}`,
63
+ `- **Website**: ${company.website ?? "—"}`,
64
+ ];
65
+ return text(lines.join("\n"));
66
+ }
67
+ catch (error) {
68
+ return failure(error);
69
+ }
70
+ });
71
+ reg.register({
72
+ name: "cw_search_contacts",
73
+ title: "Search ConnectWise Contacts",
74
+ description: "Search contacts by name, optionally within one company.",
75
+ inputSchema: {
76
+ name_contains: z.string().optional().describe("Text the first or last name contains"),
77
+ company_id: z.number().int().positive().optional().describe("Restrict to one company"),
78
+ active_only: z.boolean().default(true).describe("Exclude inactive contacts (default true)"),
79
+ page_number: pageNumberField,
80
+ page_size: pageSizeField,
81
+ response_format: responseFormatField,
82
+ },
83
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
84
+ }, async (args) => {
85
+ try {
86
+ const page = await client.getList("/company/contacts", {
87
+ conditions: allOf(args.active_only && "inactiveFlag=false", args.company_id !== undefined && `company/id=${args.company_id}`, args.name_contains &&
88
+ `(firstName contains ${q(args.name_contains)} OR lastName contains ${q(args.name_contains)})`),
89
+ orderBy: "lastName asc",
90
+ fields: CONTACT_FIELDS,
91
+ page: args.page_number,
92
+ pageSize: args.page_size,
93
+ });
94
+ if (page.items.length === 0)
95
+ return text("No contacts found.");
96
+ if (args.response_format === "json")
97
+ return text(clip(json(page)));
98
+ const lines = [`# Contacts (${page.items.length} on this page)`, ""];
99
+ for (const c of page.items) {
100
+ lines.push(`- **${c.firstName ?? ""} ${c.lastName ?? ""}** (ID: ${c.id}) — ${c.company?.name ?? "?"}${c.title ? ` — ${c.title}` : ""}${c.defaultPhoneNbr ? ` — ${c.defaultPhoneNbr}` : ""}`);
101
+ }
102
+ lines.push("", pageFooter(page.page, page.hasMore));
103
+ return text(clip(lines.join("\n")));
104
+ }
105
+ catch (error) {
106
+ return failure(error);
107
+ }
108
+ });
109
+ }
@@ -0,0 +1,80 @@
1
+ /** Configuration (device) lookup tools (read-only). */
2
+ import { z } from "zod";
3
+ import { allOf, q } from "../cw/client.js";
4
+ import { clip, failure, json, pageFooter, pageNumberField, pageSizeField, responseFormatField, text, } from "./shared.js";
5
+ const CONFIG_LIST_FIELDS = "id,name,type/name,status/name,company/name,serialNumber,ipAddress";
6
+ const CONFIG_DETAIL_FIELDS = CONFIG_LIST_FIELDS +
7
+ ",company/id,contact/name,modelNumber,tagNumber,macAddress,osType,osInfo,warrantyExpirationDate,activeFlag,notes";
8
+ export function registerConfigurationTools(reg, client) {
9
+ reg.register({
10
+ name: "cw_list_configurations",
11
+ title: "List ConnectWise Configurations",
12
+ description: "List configurations (devices/assets) — servers, firewalls, workstations — optionally filtered by " +
13
+ "company, name, or type. Useful during troubleshooting to find serials, IPs, and warranty info.",
14
+ inputSchema: {
15
+ company_id: z.number().int().positive().optional().describe("Restrict to one company"),
16
+ name_contains: z.string().optional().describe("Text the configuration name contains"),
17
+ type: z.string().optional().describe('Configuration type name (e.g. "Managed Server", "Firewall")'),
18
+ active_only: z.boolean().default(true).describe("Only active configurations (default true)"),
19
+ page_number: pageNumberField,
20
+ page_size: pageSizeField,
21
+ response_format: responseFormatField,
22
+ },
23
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
24
+ }, async (args) => {
25
+ try {
26
+ const page = await client.getList("/company/configurations", {
27
+ conditions: allOf(args.active_only && "activeFlag=true", args.company_id !== undefined && `company/id=${args.company_id}`, args.name_contains && `name contains ${q(args.name_contains)}`, args.type && `type/name=${q(args.type)}`),
28
+ orderBy: "name asc",
29
+ fields: CONFIG_LIST_FIELDS,
30
+ page: args.page_number,
31
+ pageSize: args.page_size,
32
+ });
33
+ if (page.items.length === 0)
34
+ return text("No configurations found.");
35
+ if (args.response_format === "json")
36
+ return text(clip(json(page)));
37
+ const lines = [`# Configurations (${page.items.length} on this page)`, ""];
38
+ for (const c of page.items) {
39
+ lines.push(`- **${c.name}** (ID: ${c.id}) — ${c.type?.name ?? "?"} @ ${c.company?.name ?? "?"}` +
40
+ `${c.serialNumber ? ` — SN ${c.serialNumber}` : ""}${c.ipAddress ? ` — ${c.ipAddress}` : ""}`);
41
+ }
42
+ lines.push("", pageFooter(page.page, page.hasMore));
43
+ return text(clip(lines.join("\n")));
44
+ }
45
+ catch (error) {
46
+ return failure(error);
47
+ }
48
+ });
49
+ reg.register({
50
+ name: "cw_get_configuration",
51
+ title: "Get ConnectWise Configuration",
52
+ description: "Get one configuration with full details (serial, model, network, OS, warranty, notes).",
53
+ inputSchema: {
54
+ configuration_id: z.number().int().positive().describe("The configuration ID"),
55
+ response_format: responseFormatField,
56
+ },
57
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
58
+ }, async (args) => {
59
+ try {
60
+ const c = await client.getOne(`/company/configurations/${args.configuration_id}`, CONFIG_DETAIL_FIELDS);
61
+ if (args.response_format === "json")
62
+ return text(clip(json(c)));
63
+ const lines = [
64
+ `# ${c.name} (ID: ${c.id})`,
65
+ `- **Type**: ${c.type?.name ?? "?"} | **Status**: ${c.status?.name ?? "?"} | **Active**: ${c.activeFlag ? "yes" : "no"}`,
66
+ `- **Company**: ${c.company?.name ?? "?"} | **Contact**: ${c.contact?.name ?? "—"}`,
67
+ `- **Serial**: ${c.serialNumber ?? "—"} | **Model**: ${c.modelNumber ?? "—"} | **Tag**: ${c.tagNumber ?? "—"}`,
68
+ `- **IP**: ${c.ipAddress ?? "—"} | **MAC**: ${c.macAddress ?? "—"}`,
69
+ `- **OS**: ${[c.osType, c.osInfo].filter(Boolean).join(" ") || "—"}`,
70
+ `- **Warranty until**: ${c.warrantyExpirationDate ?? "—"}`,
71
+ ];
72
+ if (c.notes)
73
+ lines.push("", "## Notes", "", String(c.notes));
74
+ return text(clip(lines.join("\n")));
75
+ }
76
+ catch (error) {
77
+ return failure(error);
78
+ }
79
+ });
80
+ }
@@ -0,0 +1,48 @@
1
+ /** Shared zod fragments and result helpers for tool implementations. */
2
+ import { z } from "zod";
3
+ import { describeError } from "../cw/client.js";
4
+ /** Hard cap on tool response size, to protect the model's context window. */
5
+ export const RESPONSE_CHAR_LIMIT = 25_000;
6
+ export const responseFormatField = z
7
+ .enum(["markdown", "json"])
8
+ .default("markdown")
9
+ .describe("Output format: human-readable markdown (default) or structured JSON");
10
+ export const pageNumberField = z
11
+ .number()
12
+ .int()
13
+ .positive()
14
+ .default(1)
15
+ .describe("Page number (default 1)");
16
+ export const pageSizeField = z
17
+ .number()
18
+ .int()
19
+ .positive()
20
+ .max(1000)
21
+ .default(25)
22
+ .describe("Results per page (default 25, max 1000)");
23
+ export function text(value) {
24
+ return { content: [{ type: "text", text: value }] };
25
+ }
26
+ export function failure(error) {
27
+ return { content: [{ type: "text", text: describeError(error) }], isError: true };
28
+ }
29
+ export function json(value) {
30
+ return JSON.stringify(value, null, 2);
31
+ }
32
+ export function clip(value, hint) {
33
+ if (value.length <= RESPONSE_CHAR_LIMIT)
34
+ return value;
35
+ const note = hint ?? "Use pagination or filters to narrow the result.";
36
+ return (value.slice(0, RESPONSE_CHAR_LIMIT) +
37
+ `\n\n---\n[Truncated at ${RESPONSE_CHAR_LIMIT.toLocaleString()} characters. ${note}]`);
38
+ }
39
+ export function pageFooter(page, hasMore) {
40
+ const lines = ["---", `Page ${page}`];
41
+ if (hasMore)
42
+ lines.push(`More available — request page_number: ${page + 1}`);
43
+ return lines.join("\n");
44
+ }
45
+ /** Message returned when a "my …" tool cannot determine the acting member. */
46
+ export const UNKNOWN_MEMBER_MESSAGE = "Error: cannot determine which ConnectWise member this session acts as. " +
47
+ "Provide the member identifier: the x-cw-member-id header (with your own API keys) " +
48
+ "or the CW_MEMBER_IDENTIFIER environment variable (server-wide keys).";