mcp-redhat-support 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +55 -0
  3. package/package.json +27 -0
  4. package/src/index.js +322 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Shon Stephens
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,55 @@
1
+ # mcp-redhat-support
2
+
3
+ An [MCP](https://modelcontextprotocol.io/) server for the Red Hat Support Case Management API. Lets AI assistants list, read, comment on, and manage Red Hat support cases.
4
+
5
+ ## Tools
6
+
7
+ | Tool | Description |
8
+ |------|-------------|
9
+ | `listCases` | List support cases with filtering by status, severity, and search string |
10
+ | `getCase` | Get full details of a specific case |
11
+ | `getCaseComments` | Get all comments on a case |
12
+ | `addCaseComment` | Add a comment to a case |
13
+ | `getCaseAttachments` | List attachments on a case |
14
+ | `downloadAttachment` | Download a case attachment to a local file |
15
+ | `uploadAttachment` | Upload a local file as an attachment to a case |
16
+ | `createCase` | Open a new support case |
17
+ | `updateCase` | Update case fields (severity, status, consultant engaged, contact, etc.) |
18
+ | `closeCase` | Close a case with an optional resolution comment |
19
+
20
+ ## Prerequisites
21
+
22
+ - Node.js 18+
23
+ - A Red Hat offline API token ([generate one here](https://access.redhat.com/management/api))
24
+
25
+ ## Usage with Claude Code
26
+
27
+ Add to `~/.claude/settings.json`:
28
+
29
+ ```json
30
+ {
31
+ "mcpServers": {
32
+ "redhat-cases": {
33
+ "command": "npx",
34
+ "args": ["-y", "mcp-redhat-support"],
35
+ "env": {
36
+ "REDHAT_TOKEN": "${REDHAT_TOKEN}"
37
+ }
38
+ }
39
+ }
40
+ }
41
+ ```
42
+
43
+ Set your token in your shell profile:
44
+
45
+ ```bash
46
+ export REDHAT_TOKEN="your-offline-token-here"
47
+ ```
48
+
49
+ ## Authentication
50
+
51
+ The server exchanges your Red Hat offline API token for a short-lived bearer token via Red Hat SSO. Tokens are cached and refreshed automatically.
52
+
53
+ ## License
54
+
55
+ MIT
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "mcp-redhat-support",
3
+ "version": "1.0.0",
4
+ "description": "MCP server for Red Hat Support Case Management API",
5
+ "type": "module",
6
+ "bin": {
7
+ "mcp-redhat-support": "./src/index.js"
8
+ },
9
+ "main": "./src/index.js",
10
+ "scripts": {
11
+ "start": "node src/index.js"
12
+ },
13
+ "keywords": ["mcp", "redhat", "support", "cases"],
14
+ "license": "MIT",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/shonstephens/mcp-redhat-support.git"
18
+ },
19
+ "homepage": "https://github.com/shonstephens/mcp-redhat-support#readme",
20
+ "bugs": {
21
+ "url": "https://github.com/shonstephens/mcp-redhat-support/issues"
22
+ },
23
+ "dependencies": {
24
+ "@modelcontextprotocol/sdk": "^1.26.0",
25
+ "zod": "^3.25.0"
26
+ }
27
+ }
package/src/index.js ADDED
@@ -0,0 +1,322 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+ import { z } from "zod";
6
+ import { writeFile, readFile, stat } from "node:fs/promises";
7
+ import { basename } from "node:path";
8
+
9
+ const TOKEN_URL = "https://sso.redhat.com/auth/realms/redhat-external/protocol/openid-connect/token";
10
+ const API_BASE = "https://api.access.redhat.com/support/v1";
11
+ const CLIENT_ID = "rhsm-api";
12
+
13
+ let cachedToken = null;
14
+ let tokenExpiry = 0;
15
+
16
+ async function getAccessToken() {
17
+ if (cachedToken && Date.now() < tokenExpiry) {
18
+ return cachedToken;
19
+ }
20
+
21
+ const offlineToken = process.env.REDHAT_TOKEN;
22
+ if (!offlineToken) {
23
+ throw new Error("REDHAT_TOKEN environment variable is required (Red Hat offline API token)");
24
+ }
25
+
26
+ const res = await fetch(TOKEN_URL, {
27
+ method: "POST",
28
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
29
+ body: new URLSearchParams({
30
+ grant_type: "refresh_token",
31
+ client_id: CLIENT_ID,
32
+ refresh_token: offlineToken,
33
+ }),
34
+ });
35
+
36
+ if (!res.ok) {
37
+ const text = await res.text();
38
+ throw new Error(`Token exchange failed (${res.status}): ${text}`);
39
+ }
40
+
41
+ const data = await res.json();
42
+ cachedToken = data.access_token;
43
+ // Expire 60s early to avoid edge cases
44
+ tokenExpiry = Date.now() + (data.expires_in - 60) * 1000;
45
+ return cachedToken;
46
+ }
47
+
48
+ async function apiRequest(path, options = {}) {
49
+ const token = await getAccessToken();
50
+ const url = `${API_BASE}${path}`;
51
+ const res = await fetch(url, {
52
+ ...options,
53
+ headers: {
54
+ Authorization: `Bearer ${token}`,
55
+ Accept: "application/json",
56
+ "Content-Type": "application/json",
57
+ ...options.headers,
58
+ },
59
+ });
60
+
61
+ if (!res.ok) {
62
+ const text = await res.text();
63
+ throw new Error(`API request failed (${res.status} ${res.statusText}): ${text}`);
64
+ }
65
+
66
+ const text = await res.text();
67
+ return text ? JSON.parse(text) : null;
68
+ }
69
+
70
+ const server = new McpServer({
71
+ name: "mcp-redhat-support",
72
+ version: "1.0.0",
73
+ });
74
+
75
+ // --- Tools ---
76
+
77
+ server.tool(
78
+ "listCases",
79
+ "List Red Hat support cases with optional filtering",
80
+ {
81
+ accountNumber: z.string().describe("Red Hat account number (required to scope results)"),
82
+ maxResults: z.number().optional().default(10).describe("Maximum number of cases to return"),
83
+ offset: z.number().optional().default(0).describe("Pagination offset"),
84
+ status: z.string().optional().describe("Filter by status (e.g. 'Open', 'Closed', 'Waiting on Customer')"),
85
+ severity: z.string().optional().describe("Filter by severity (e.g. '1 (Urgent)', '2 (High)', '3 (Normal)', '4 (Low)')"),
86
+ searchString: z.string().optional().describe("Search string to filter cases"),
87
+ },
88
+ async ({ accountNumber, maxResults, offset, status, severity, searchString }) => {
89
+ const body = { accountNumber, maxResults, offset };
90
+ if (status) body.status = status;
91
+ if (severity) body.severity = severity;
92
+ if (searchString) body.searchString = searchString;
93
+
94
+ const data = await apiRequest("/cases/filter", {
95
+ method: "POST",
96
+ body: JSON.stringify(body),
97
+ });
98
+
99
+ return {
100
+ content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
101
+ };
102
+ }
103
+ );
104
+
105
+ server.tool(
106
+ "getCase",
107
+ "Get full details of a specific Red Hat support case",
108
+ {
109
+ caseNumber: z.string().describe("The case number (e.g. '04371920')"),
110
+ },
111
+ async ({ caseNumber }) => {
112
+ const data = await apiRequest(`/cases/${caseNumber}`);
113
+ return {
114
+ content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
115
+ };
116
+ }
117
+ );
118
+
119
+ server.tool(
120
+ "getCaseComments",
121
+ "Get all comments on a Red Hat support case",
122
+ {
123
+ caseNumber: z.string().describe("The case number"),
124
+ },
125
+ async ({ caseNumber }) => {
126
+ const data = await apiRequest(`/cases/${caseNumber}/comments`);
127
+ return {
128
+ content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
129
+ };
130
+ }
131
+ );
132
+
133
+ server.tool(
134
+ "addCaseComment",
135
+ "Add a comment to a Red Hat support case",
136
+ {
137
+ caseNumber: z.string().describe("The case number"),
138
+ commentBody: z.string().describe("The comment text to add"),
139
+ isPublic: z.boolean().optional().default(true).describe("Whether the comment is visible to Red Hat (true) or internal/private (false)"),
140
+ },
141
+ async ({ caseNumber, commentBody, isPublic }) => {
142
+ const payload = { commentBody, isPublic };
143
+ const data = await apiRequest(`/cases/${caseNumber}/comments`, {
144
+ method: "POST",
145
+ body: JSON.stringify(payload),
146
+ });
147
+ return {
148
+ content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
149
+ };
150
+ }
151
+ );
152
+
153
+ server.tool(
154
+ "getCaseAttachments",
155
+ "List attachments on a Red Hat support case",
156
+ {
157
+ caseNumber: z.string().describe("The case number"),
158
+ },
159
+ async ({ caseNumber }) => {
160
+ const data = await apiRequest(`/cases/${caseNumber}/attachments`);
161
+ return {
162
+ content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
163
+ };
164
+ }
165
+ );
166
+
167
+ server.tool(
168
+ "downloadAttachment",
169
+ "Download a case attachment to a local file",
170
+ {
171
+ caseNumber: z.string().describe("The case number"),
172
+ attachmentUuid: z.string().describe("The attachment UUID (from getCaseAttachments)"),
173
+ outputPath: z.string().describe("Local file path to save the attachment to"),
174
+ },
175
+ async ({ caseNumber, attachmentUuid, outputPath }) => {
176
+ const token = await getAccessToken();
177
+ const url = `https://attachments.access.redhat.com/hydra/rest/cases/${caseNumber}/attachments/${attachmentUuid}`;
178
+ const res = await fetch(url, {
179
+ headers: { Authorization: `Bearer ${token}` },
180
+ });
181
+ if (!res.ok) {
182
+ const text = await res.text();
183
+ throw new Error(`Download failed (${res.status}): ${text}`);
184
+ }
185
+ const buffer = Buffer.from(await res.arrayBuffer());
186
+ await writeFile(outputPath, buffer);
187
+ return {
188
+ content: [{ type: "text", text: `Downloaded ${buffer.length} bytes to ${outputPath}` }],
189
+ };
190
+ }
191
+ );
192
+
193
+ server.tool(
194
+ "uploadAttachment",
195
+ "Upload a local file as an attachment to a case",
196
+ {
197
+ caseNumber: z.string().describe("The case number"),
198
+ filePath: z.string().describe("Local file path to upload"),
199
+ },
200
+ async ({ caseNumber, filePath }) => {
201
+ const token = await getAccessToken();
202
+ const fileName = basename(filePath);
203
+ const fileData = await readFile(filePath);
204
+ const fileInfo = await stat(filePath);
205
+
206
+ const form = new FormData();
207
+ form.append("file", new Blob([fileData]), fileName);
208
+
209
+ const url = `${API_BASE}/cases/${caseNumber}/attachments`;
210
+ const res = await fetch(url, {
211
+ method: "POST",
212
+ headers: { Authorization: `Bearer ${token}` },
213
+ body: form,
214
+ });
215
+ if (!res.ok) {
216
+ const text = await res.text();
217
+ throw new Error(`Upload failed (${res.status}): ${text}`);
218
+ }
219
+ const data = await res.json().catch(() => ({}));
220
+ return {
221
+ content: [{ type: "text", text: `Uploaded ${fileName} (${fileInfo.size} bytes) to case ${caseNumber}\n${JSON.stringify(data, null, 2)}` }],
222
+ };
223
+ }
224
+ );
225
+
226
+ server.tool(
227
+ "createCase",
228
+ "Create a new Red Hat support case",
229
+ {
230
+ summary: z.string().describe("Case summary/title"),
231
+ description: z.string().describe("Detailed description of the issue"),
232
+ product: z.string().describe("Product name (e.g. 'Red Hat Enterprise Linux', 'OpenShift Container Platform')"),
233
+ version: z.string().describe("Product version (e.g. '9.4', '4.16')"),
234
+ severity: z.string().optional().default("4 (Low)").describe("Severity: '1 (Urgent)', '2 (High)', '3 (Normal)', '4 (Low)'"),
235
+ accountNumber: z.string().describe("Red Hat account number"),
236
+ cep: z.boolean().optional().default(false).describe("Consultant Engaged — set true if a consultant is working the case"),
237
+ contactName: z.string().optional().describe("Primary contact name for the case"),
238
+ },
239
+ async ({ summary, description, product, version, severity, accountNumber, cep, contactName }) => {
240
+ const body = { summary, description, product, version, severity, accountNumber, cep };
241
+ if (contactName) body.contactName = contactName;
242
+ const result = await apiRequest("/cases", {
243
+ method: "POST",
244
+ body: JSON.stringify(body),
245
+ });
246
+ // Response is { location: [".../<caseNumber>"] } — extract and fetch full case
247
+ const loc = result?.location?.[0] || "";
248
+ const newCaseNumber = loc.split("/").pop();
249
+ if (newCaseNumber) {
250
+ const data = await apiRequest(`/cases/${newCaseNumber}`);
251
+ return {
252
+ content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
253
+ };
254
+ }
255
+ return {
256
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
257
+ };
258
+ }
259
+ );
260
+
261
+ server.tool(
262
+ "updateCase",
263
+ "Update fields on an existing Red Hat support case",
264
+ {
265
+ caseNumber: z.string().describe("The case number"),
266
+ severity: z.string().optional().describe("Severity: '1 (Urgent)', '2 (High)', '3 (Normal)', '4 (Low)'"),
267
+ status: z.string().optional().describe("Case status (e.g. 'Waiting on Red Hat', 'Waiting on Customer')"),
268
+ cep: z.boolean().optional().describe("Consultant Engaged flag"),
269
+ contactName: z.string().optional().describe("Primary contact name"),
270
+ alternateId: z.string().optional().describe("Alternate contact/tracking reference"),
271
+ summary: z.string().optional().describe("Update the case summary"),
272
+ description: z.string().optional().describe("Update the case description"),
273
+ },
274
+ async ({ caseNumber, ...fields }) => {
275
+ const body = {};
276
+ for (const [k, v] of Object.entries(fields)) {
277
+ if (v !== undefined) body[k] = v;
278
+ }
279
+ if (Object.keys(body).length === 0) {
280
+ return { content: [{ type: "text", text: "No fields to update" }] };
281
+ }
282
+ await apiRequest(`/cases/${caseNumber}`, {
283
+ method: "PUT",
284
+ body: JSON.stringify(body),
285
+ });
286
+ // Fetch updated case to confirm
287
+ const data = await apiRequest(`/cases/${caseNumber}`);
288
+ return {
289
+ content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
290
+ };
291
+ }
292
+ );
293
+
294
+ server.tool(
295
+ "closeCase",
296
+ "Close a Red Hat support case",
297
+ {
298
+ caseNumber: z.string().describe("The case number"),
299
+ closureComment: z.string().optional().describe("Optional closing comment/resolution summary"),
300
+ },
301
+ async ({ caseNumber, closureComment }) => {
302
+ if (closureComment) {
303
+ await apiRequest(`/cases/${caseNumber}/comments`, {
304
+ method: "POST",
305
+ body: JSON.stringify({ commentBody: closureComment, isPublic: true }),
306
+ });
307
+ }
308
+ await apiRequest(`/cases/${caseNumber}`, {
309
+ method: "PUT",
310
+ body: JSON.stringify({ status: "Closed" }),
311
+ });
312
+ const data = await apiRequest(`/cases/${caseNumber}`);
313
+ return {
314
+ content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
315
+ };
316
+ }
317
+ );
318
+
319
+ // --- Start server ---
320
+
321
+ const transport = new StdioServerTransport();
322
+ await server.connect(transport);