@jgardner04/ghost-mcp-server 1.14.0 → 1.14.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jgardner04/ghost-mcp-server",
3
- "version": "1.14.0",
3
+ "version": "1.14.1",
4
4
  "description": "A Model Context Protocol (MCP) server for interacting with Ghost CMS via the Admin API",
5
5
  "author": "Jonathan Gardner",
6
6
  "type": "module",
package/src/mcp_server.js CHANGED
@@ -8,8 +8,9 @@ import fs from 'fs';
8
8
  import path from 'path';
9
9
  import os from 'os';
10
10
  import crypto from 'crypto';
11
- import { ValidationError } from './errors/index.js';
12
11
  import { validateToolInput } from './utils/validation.js';
12
+ import { formatErrorResponse } from './utils/formatErrorResponse.js';
13
+ import { createContextLogger } from './utils/logger.js';
13
14
  import { trackTempFile, cleanupTempFiles } from './utils/tempFileManager.js';
14
15
  import { resolveLocalImagePath, decodeBase64ToTempFile } from './utils/imageInputResolver.js';
15
16
  import {
@@ -38,6 +39,24 @@ import {
38
39
  // Load environment variables
39
40
  dotenv.config({ quiet: true });
40
41
 
42
+ const mcpLogger = createContextLogger('mcp-server');
43
+
44
+ /**
45
+ * Emit structured log fields for a caught error. Never passes the raw error
46
+ * object to the logger — Ghost SDK errors carry request headers/URLs that
47
+ * include credentials.
48
+ */
49
+ const logToolError = (toolName, error, extra = {}) => {
50
+ mcpLogger.error(`Tool ${toolName} failed`, {
51
+ tool: toolName,
52
+ errorName: error?.name,
53
+ errorMessage: error?.message,
54
+ errorCode: error?.code,
55
+ ghostStatusCode: error?.ghostStatusCode,
56
+ ...extra,
57
+ });
58
+ };
59
+
41
60
  // Lazy-loaded modules (to avoid Node.js v25 Buffer compatibility issues at startup)
42
61
  let ghostService = null;
43
62
  let postService = null;
@@ -99,7 +118,6 @@ const escapeNqlValue = (value) => {
99
118
  * @returns {Function} Wrapped async handler for server.registerTool
100
119
  */
101
120
  const withErrorHandling = (toolName, schema, handler) => {
102
- const zodContext = toolName.replace('ghost_', '').replace(/_/g, ' ');
103
121
  return async (rawInput) => {
104
122
  console.error(`Executing tool: ${toolName}`);
105
123
  const validation = validateToolInput(schema, rawInput, toolName);
@@ -111,18 +129,8 @@ const withErrorHandling = (toolName, schema, handler) => {
111
129
  await loadServices();
112
130
  return await handler(validation.data);
113
131
  } catch (error) {
114
- console.error(`Error in ${toolName}:`, error);
115
- if (error.name === 'ZodError') {
116
- const validationError = ValidationError.fromZod(error, zodContext);
117
- return {
118
- content: [{ type: 'text', text: JSON.stringify(validationError.toJSON(), null, 2) }],
119
- isError: true,
120
- };
121
- }
122
- return {
123
- content: [{ type: 'text', text: `Error in ${toolName}: ${error.message}` }],
124
- isError: true,
125
- };
132
+ logToolError(toolName, error);
133
+ return formatErrorResponse(error, toolName);
126
134
  }
127
135
  };
128
136
  };
@@ -452,11 +460,8 @@ server.registerTool(
452
460
  if (uploadResult.ref) result.ref = uploadResult.ref;
453
461
  return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
454
462
  } catch (error) {
455
- console.error(`Error in ghost_upload_image:`, error);
456
- return {
457
- content: [{ type: 'text', text: `Error uploading image: ${error.message}` }],
458
- isError: true,
459
- };
463
+ logToolError('ghost_upload_image', error);
464
+ return formatErrorResponse(error, 'ghost_upload_image');
460
465
  }
461
466
  }
462
467
  );
@@ -502,11 +507,8 @@ server.registerTool(
502
507
  uploadedRef = uploadResult.ref;
503
508
  altText = finalAltText;
504
509
  } catch (error) {
505
- console.error(`ghost_set_feature_image: upload failed`, error);
506
- return {
507
- content: [{ type: 'text', text: `Upload failed: ${error.message}` }],
508
- isError: true,
509
- };
510
+ logToolError('ghost_set_feature_image', error, { phase: 'upload' });
511
+ return formatErrorResponse(error, 'ghost_set_feature_image');
510
512
  }
511
513
 
512
514
  const updatePayload = {
@@ -534,24 +536,14 @@ server.registerTool(
534
536
  ],
535
537
  };
536
538
  } catch (error) {
537
- console.error(`ghost_set_feature_image: update failed (orphaned ${uploadedUrl})`, error);
538
- return {
539
- content: [
540
- {
541
- type: 'text',
542
- text: JSON.stringify(
543
- {
544
- error: `Upload succeeded but ${type} update failed: ${error.message}`,
545
- orphanedImage: { url: uploadedUrl, ref: uploadedRef, alt: altText },
546
- hint: 'Ghost does not expose a delete-image endpoint; reuse this URL or leave it orphaned.',
547
- },
548
- null,
549
- 2
550
- ),
551
- },
552
- ],
553
- isError: true,
554
- };
539
+ logToolError('ghost_set_feature_image', error, {
540
+ phase: 'update',
541
+ orphanedUrl: uploadedUrl,
542
+ });
543
+ return formatErrorResponse(error, 'ghost_set_feature_image', {
544
+ orphanedImage: { url: uploadedUrl, ref: uploadedRef, alt: altText },
545
+ hint: 'Ghost does not expose a delete-image endpoint; reuse this URL or leave it orphaned.',
546
+ });
555
547
  }
556
548
  }
557
549
  );
@@ -0,0 +1,158 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { formatErrorResponse } from '../formatErrorResponse.js';
3
+ import { GhostAPIError, ValidationError, NotFoundError } from '../../errors/index.js';
4
+
5
+ function parseJsonBlock(text) {
6
+ const match = text.match(/```json\n([\s\S]+?)\n```/);
7
+ expect(match, `no JSON block in: ${text}`).toBeTruthy();
8
+ return JSON.parse(match[1]);
9
+ }
10
+
11
+ describe('formatErrorResponse', () => {
12
+ const originalEnv = process.env.GHOST_ADMIN_API_KEY;
13
+
14
+ beforeEach(() => {
15
+ process.env.GHOST_ADMIN_API_KEY = '';
16
+ });
17
+
18
+ afterEach(() => {
19
+ if (originalEnv === undefined) delete process.env.GHOST_ADMIN_API_KEY;
20
+ else process.env.GHOST_ADMIN_API_KEY = originalEnv;
21
+ });
22
+
23
+ it('returns consistent envelope with error key for generic Error (no ghost key)', () => {
24
+ const response = formatErrorResponse(new Error('boom'), 'ghost_get_posts');
25
+ expect(response.isError).toBe(true);
26
+ expect(response.content[0].type).toBe('text');
27
+ const envelope = parseJsonBlock(response.content[0].text);
28
+ expect(envelope.error).toBeDefined();
29
+ expect(envelope.error.message).toBe('boom');
30
+ expect(envelope).not.toHaveProperty('ghost');
31
+ expect(response.content[0].text).toContain('Error in ghost_get_posts: boom');
32
+ });
33
+
34
+ it('includes gated ghost sub-object for GhostAPIError', () => {
35
+ const err = new GhostAPIError('posts.edit', 'Title is required', 422);
36
+ const response = formatErrorResponse(err, 'ghost_update_post');
37
+ const envelope = parseJsonBlock(response.content[0].text);
38
+ expect(envelope.error.name).toBe('GhostAPIError');
39
+ expect(envelope.error.code).toBe('GHOST_VALIDATION_ERROR');
40
+ expect(envelope.ghost).toBeDefined();
41
+ expect(envelope.ghost.operation).toBe('posts.edit');
42
+ expect(envelope.ghost.statusCode).toBe(422);
43
+ expect(envelope.ghost.originalMessage).toBe('Title is required');
44
+ expect(response.content[0].text).toContain('422');
45
+ expect(response.content[0].text).toContain('posts.edit');
46
+ expect(response.content[0].text).toContain('Title is required');
47
+ });
48
+
49
+ it('uses raw ghostStatusCode (not remapped statusCode) in ghost envelope', () => {
50
+ const err = new GhostAPIError('posts.edit', 'bad', 422);
51
+ // GhostAPIError remaps 422 -> 400 for .statusCode; ghost.statusCode must be 422
52
+ expect(err.statusCode).toBe(400);
53
+ expect(err.ghostStatusCode).toBe(422);
54
+ const envelope = parseJsonBlock(formatErrorResponse(err, 'ghost_update_post').content[0].text);
55
+ expect(envelope.ghost.statusCode).toBe(422);
56
+ expect(envelope.error.statusCode).toBe(400);
57
+ });
58
+
59
+ it('does not leak GHOST_ADMIN_API_KEY in surfaced response', () => {
60
+ process.env.GHOST_ADMIN_API_KEY = 'plaintext-admin-key-xyz';
61
+ const err = new GhostAPIError(
62
+ 'posts.edit',
63
+ 'Ghost complained: token plaintext-admin-key-xyz invalid',
64
+ 401
65
+ );
66
+ const response = formatErrorResponse(err, 'ghost_update_post');
67
+ expect(response.content[0].text).not.toContain('plaintext-admin-key-xyz');
68
+ expect(response.content[0].text).toContain('[REDACTED]');
69
+ });
70
+
71
+ it('does not leak Ghost-shaped admin key pattern in originalMessage', () => {
72
+ const fakeKey = `${'1'.repeat(24)}:${'2'.repeat(64)}`;
73
+ const err = new GhostAPIError('posts.edit', `failed with ${fakeKey}`, 401);
74
+ const response = formatErrorResponse(err, 'ghost_update_post');
75
+ expect(response.content[0].text).not.toContain(fakeKey);
76
+ });
77
+
78
+ it('produces envelope for ValidationError (no ghost key)', () => {
79
+ const err = new ValidationError('Validation failed', [
80
+ { field: 'title', message: 'required', type: 'invalid_type' },
81
+ ]);
82
+ const response = formatErrorResponse(err, 'ghost_update_post');
83
+ const envelope = parseJsonBlock(response.content[0].text);
84
+ expect(envelope.error.code).toBe('VALIDATION_ERROR');
85
+ expect(envelope).not.toHaveProperty('ghost');
86
+ });
87
+
88
+ it('produces envelope for NotFoundError (no ghost key)', () => {
89
+ const err = new NotFoundError('Post', 'abc');
90
+ const response = formatErrorResponse(err, 'ghost_get_post');
91
+ const envelope = parseJsonBlock(response.content[0].text);
92
+ expect(envelope.error.code).toBe('NOT_FOUND');
93
+ expect(envelope).not.toHaveProperty('ghost');
94
+ });
95
+
96
+ describe('extra context', () => {
97
+ it('includes and sanitizes extra context when provided', () => {
98
+ const extra = {
99
+ orphanedImage: { url: 'https://cdn.example/img.jpg?key=LEAK_ME_XYZ', ref: 'r1' },
100
+ };
101
+ const response = formatErrorResponse(new Error('boom'), 'ghost_set_feature_image', extra);
102
+ const envelope = parseJsonBlock(response.content[0].text);
103
+ expect(envelope.extra).toBeDefined();
104
+ expect(envelope.extra.orphanedImage.url).toContain('key=[REDACTED]');
105
+ expect(response.content[0].text).not.toContain('LEAK_ME_XYZ');
106
+ });
107
+
108
+ it('omits extra key entirely when arg is not provided', () => {
109
+ const envelope = parseJsonBlock(
110
+ formatErrorResponse(new Error('boom'), 'ghost_update_post').content[0].text
111
+ );
112
+ expect(envelope).not.toHaveProperty('extra');
113
+ });
114
+
115
+ it('omits extra key when arg is empty object (no empty-object leak)', () => {
116
+ const envelope = parseJsonBlock(
117
+ formatErrorResponse(new Error('boom'), 'ghost_update_post', {}).content[0].text
118
+ );
119
+ expect(envelope).not.toHaveProperty('extra');
120
+ });
121
+
122
+ it('combines error, ghost, and extra when all apply', () => {
123
+ const err = new GhostAPIError('posts.edit', 'bad', 422);
124
+ const envelope = parseJsonBlock(
125
+ formatErrorResponse(err, 'ghost_set_feature_image', { orphanedImage: { url: 'x' } })
126
+ .content[0].text
127
+ );
128
+ expect(envelope.error).toBeDefined();
129
+ expect(envelope.ghost).toBeDefined();
130
+ expect(envelope.extra).toBeDefined();
131
+ });
132
+ });
133
+
134
+ describe('ZodError coercion', () => {
135
+ it('coerces ZodError-shaped input to VALIDATION_ERROR / 400 envelope', () => {
136
+ const zodLike = Object.assign(new Error('zod'), {
137
+ name: 'ZodError',
138
+ issues: [{ path: ['purpose'], message: 'Invalid enum value', code: 'invalid_enum_value' }],
139
+ });
140
+ const envelope = parseJsonBlock(
141
+ formatErrorResponse(zodLike, 'ghost_upload_image').content[0].text
142
+ );
143
+ expect(envelope.error.code).toBe('VALIDATION_ERROR');
144
+ expect(envelope.error.statusCode).toBe(400);
145
+ expect(envelope.error.errors).toEqual([
146
+ { field: 'purpose', message: 'Invalid enum value', type: 'invalid_enum_value' },
147
+ ]);
148
+ });
149
+
150
+ it('does not coerce a non-ZodError error that happens to have an issues field', () => {
151
+ const notZod = Object.assign(new Error('unrelated'), { issues: [] });
152
+ const envelope = parseJsonBlock(
153
+ formatErrorResponse(notZod, 'ghost_update_post').content[0].text
154
+ );
155
+ expect(envelope.error.code).toBe('UNKNOWN');
156
+ });
157
+ });
158
+ });
@@ -0,0 +1,130 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { sanitizeErrorPayload } from '../sanitizeErrorPayload.js';
3
+
4
+ describe('sanitizeErrorPayload', () => {
5
+ const originalEnv = process.env.GHOST_ADMIN_API_KEY;
6
+
7
+ beforeEach(() => {
8
+ process.env.GHOST_ADMIN_API_KEY = '';
9
+ });
10
+
11
+ afterEach(() => {
12
+ if (originalEnv === undefined) {
13
+ delete process.env.GHOST_ADMIN_API_KEY;
14
+ } else {
15
+ process.env.GHOST_ADMIN_API_KEY = originalEnv;
16
+ }
17
+ });
18
+
19
+ it('redacts GHOST_ADMIN_API_KEY when it appears verbatim in a nested string', () => {
20
+ process.env.GHOST_ADMIN_API_KEY = 'super-secret-env-key-value';
21
+ const input = {
22
+ error: { message: 'Leaked super-secret-env-key-value in text' },
23
+ ghost: { originalMessage: 'also super-secret-env-key-value here' },
24
+ };
25
+ const out = sanitizeErrorPayload(input);
26
+ expect(out.error.message).not.toContain('super-secret-env-key-value');
27
+ expect(out.error.message).toContain('[REDACTED]');
28
+ expect(out.ghost.originalMessage).not.toContain('super-secret-env-key-value');
29
+ });
30
+
31
+ it('redacts Ghost-shaped admin key literal embedded in text', () => {
32
+ const key = `${'a'.repeat(24)}:${'b'.repeat(64)}`;
33
+ const out = sanitizeErrorPayload({
34
+ error: { message: `Bad auth with ${key} failed` },
35
+ });
36
+ expect(out.error.message).not.toContain(key);
37
+ expect(out.error.message).toContain('[REDACTED]');
38
+ });
39
+
40
+ it('redacts key= token= and access_token= query params on URLs', () => {
41
+ const out = sanitizeErrorPayload({
42
+ error: { message: 'GET https://ghost.example/admin?key=abc123&other=fine' },
43
+ ghost: {
44
+ originalMessage: 'https://x/y?token=xyz and https://x/y?access_token=qqq',
45
+ },
46
+ });
47
+ expect(out.error.message).toContain('key=[REDACTED]');
48
+ expect(out.error.message).toContain('other=fine');
49
+ expect(out.ghost.originalMessage).toContain('token=[REDACTED]');
50
+ expect(out.ghost.originalMessage).toContain('access_token=[REDACTED]');
51
+ });
52
+
53
+ it('redacts Authorization / Cookie header-style substrings', () => {
54
+ const out = sanitizeErrorPayload({
55
+ error: { message: 'Authorization: Bearer abc.def.ghi and Cookie: sess=xyz' },
56
+ });
57
+ expect(out.error.message).not.toContain('abc.def.ghi');
58
+ expect(out.error.message).not.toContain('sess=xyz');
59
+ expect(out.error.message).toContain('Authorization');
60
+ expect(out.error.message).toContain('[REDACTED]');
61
+ });
62
+
63
+ it('redacts every value in a multi-value Cookie header', () => {
64
+ const out = sanitizeErrorPayload({
65
+ error: { message: 'Cookie: a=first; b=SECRET_SESSION; c=third' },
66
+ });
67
+ expect(out.error.message).not.toContain('SECRET_SESSION');
68
+ expect(out.error.message).not.toContain('a=first');
69
+ expect(out.error.message).not.toContain('c=third');
70
+ expect(out.error.message).toContain('Cookie: [REDACTED]');
71
+ });
72
+
73
+ it('redacts Set-Cookie value but preserves attributes (HttpOnly, Secure, Path)', () => {
74
+ const out = sanitizeErrorPayload({
75
+ error: { message: 'Set-Cookie: sess=TOKEN_XYZ; HttpOnly; Secure; Path=/' },
76
+ });
77
+ expect(out.error.message).not.toContain('TOKEN_XYZ');
78
+ expect(out.error.message).toContain('Set-Cookie: [REDACTED]');
79
+ expect(out.error.message).toContain('HttpOnly');
80
+ expect(out.error.message).toContain('Secure');
81
+ });
82
+
83
+ it('redacts secrets inside string elements of arrays (truncation flag no longer needed)', () => {
84
+ const out = sanitizeErrorPayload({
85
+ ghost: {
86
+ originalMessage: ['https://x/y?key=LEAKED_1', 'https://x/y?token=LEAKED_2'],
87
+ },
88
+ });
89
+ expect(JSON.stringify(out)).not.toContain('LEAKED_1');
90
+ expect(JSON.stringify(out)).not.toContain('LEAKED_2');
91
+ expect(out.ghost.originalMessage[0]).toContain('key=[REDACTED]');
92
+ expect(out.ghost.originalMessage[1]).toContain('token=[REDACTED]');
93
+ });
94
+
95
+ it('leaves benign content untouched', () => {
96
+ const input = {
97
+ error: { message: 'Title is required', code: 'GHOST_VALIDATION_ERROR', statusCode: 400 },
98
+ ghost: {
99
+ operation: 'posts.edit',
100
+ statusCode: 422,
101
+ originalMessage: 'Post title cannot be blank',
102
+ },
103
+ };
104
+ const out = sanitizeErrorPayload(input);
105
+ expect(out).toEqual(input);
106
+ });
107
+
108
+ it('truncates long originalMessage', () => {
109
+ const longMsg = 'x'.repeat(5000);
110
+ const out = sanitizeErrorPayload({
111
+ ghost: { originalMessage: longMsg },
112
+ });
113
+ expect(out.ghost.originalMessage.length).toBeLessThan(longMsg.length);
114
+ expect(out.ghost.originalMessage).toContain('[truncated]');
115
+ });
116
+
117
+ it('does not redact env key when env var is empty', () => {
118
+ process.env.GHOST_ADMIN_API_KEY = '';
119
+ const out = sanitizeErrorPayload({ error: { message: 'harmless text' } });
120
+ expect(out.error.message).toBe('harmless text');
121
+ });
122
+
123
+ it('does not mutate the input object', () => {
124
+ process.env.GHOST_ADMIN_API_KEY = 'SECRET';
125
+ const input = { error: { message: 'contains SECRET' } };
126
+ const snapshot = JSON.parse(JSON.stringify(input));
127
+ sanitizeErrorPayload(input);
128
+ expect(input).toEqual(snapshot);
129
+ });
130
+ });
@@ -54,11 +54,14 @@ describe('validateToolInput', () => {
54
54
  const result = validateToolInput(testSchema, { name: '' }, 'test_tool');
55
55
 
56
56
  expect(result.success).toBe(false);
57
- const errorObj = JSON.parse(result.errorResponse.content[0].text);
58
- expect(errorObj.name).toBe('ValidationError');
59
- expect(errorObj.code).toBe('VALIDATION_ERROR');
60
- expect(errorObj.statusCode).toBe(400);
61
- expect(errorObj.message).toContain('Validation failed');
57
+ const text = result.errorResponse.content[0].text;
58
+ const jsonMatch = text.match(/```json\n([\s\S]+?)\n```/);
59
+ expect(jsonMatch).toBeTruthy();
60
+ const envelope = JSON.parse(jsonMatch[1]);
61
+ expect(envelope.error.name).toBe('ValidationError');
62
+ expect(envelope.error.code).toBe('VALIDATION_ERROR');
63
+ expect(envelope.error.statusCode).toBe(400);
64
+ expect(envelope.error.message).toContain('Validation failed');
62
65
  });
63
66
  });
64
67
 
@@ -141,8 +144,11 @@ describe('validateToolInput', () => {
141
144
 
142
145
  expect(result.success).toBe(false);
143
146
  expect(result.errorResponse.isError).toBe(true);
144
- const errorObj = JSON.parse(result.errorResponse.content[0].text);
145
- expect(errorObj.code).toBe('VALIDATION_ERROR');
147
+ const text = result.errorResponse.content[0].text;
148
+ const jsonMatch = text.match(/```json\n([\s\S]+?)\n```/);
149
+ expect(jsonMatch).toBeTruthy();
150
+ const envelope = JSON.parse(jsonMatch[1]);
151
+ expect(envelope.error.code).toBe('VALIDATION_ERROR');
146
152
  });
147
153
 
148
154
  it('should pass validation when refinement is satisfied', () => {
@@ -0,0 +1,63 @@
1
+ import { BaseError, GhostAPIError, ValidationError } from '../errors/index.js';
2
+ import { sanitizeErrorPayload } from './sanitizeErrorPayload.js';
3
+
4
+ /**
5
+ * Builds an MCP tool error response with a consistent envelope shape.
6
+ * All errors produce `{ error: {...} }`; GhostAPIError additionally includes
7
+ * a gated `ghost` sub-object with Ghost-specific diagnostic fields. Callers
8
+ * may pass an optional `extra` object whose contents will be merged into the
9
+ * envelope under an `extra` key and sanitized alongside the rest.
10
+ *
11
+ * @param {Error} error - The caught error.
12
+ * @param {string} toolName - MCP tool name for the human-readable summary line.
13
+ * @param {object} [extra] - Optional caller-supplied context; sanitized with the envelope.
14
+ * @returns {{content: {type: string, text: string}[], isError: true}}
15
+ */
16
+ export function formatErrorResponse(error, toolName, extra) {
17
+ // Duck-type ZodError so we don't couple this module to the zod runtime.
18
+ const normalized =
19
+ error?.name === 'ZodError' && Array.isArray(error.issues)
20
+ ? ValidationError.fromZod(error, toolName)
21
+ : error;
22
+
23
+ const base =
24
+ normalized instanceof BaseError
25
+ ? normalized.toJSON()
26
+ : {
27
+ name: normalized?.name || 'Error',
28
+ message: normalized?.message || String(normalized),
29
+ code: 'UNKNOWN',
30
+ statusCode: 500,
31
+ };
32
+
33
+ const envelope = { error: base };
34
+
35
+ if (normalized instanceof GhostAPIError) {
36
+ envelope.ghost = {
37
+ operation: normalized.operation,
38
+ statusCode: normalized.ghostStatusCode,
39
+ // normalized.originalError is already a string (coerced by ExternalServiceError constructor);
40
+ // coerce again defensively so the sanitizer always receives a string, not an Error object.
41
+ originalMessage:
42
+ typeof normalized.originalError === 'string'
43
+ ? normalized.originalError
44
+ : (normalized.originalError?.message ?? String(normalized.originalError)),
45
+ };
46
+ }
47
+
48
+ if (extra && typeof extra === 'object' && Object.keys(extra).length > 0) {
49
+ envelope.extra = extra;
50
+ }
51
+
52
+ const sanitized = sanitizeErrorPayload(envelope);
53
+ const summary = sanitized.ghost
54
+ ? `Error in ${toolName}: ${sanitized.error.name} [${sanitized.ghost.statusCode ?? '?'} ${sanitized.error.code}] ${sanitized.ghost.operation ?? '?'}: ${sanitized.ghost.originalMessage ?? sanitized.error.message}`
55
+ : `Error in ${toolName}: ${sanitized.error.message}`;
56
+
57
+ const body = `${summary}\n\n\`\`\`json\n${JSON.stringify(sanitized, null, 2)}\n\`\`\``;
58
+
59
+ return {
60
+ content: [{ type: 'text', text: body }],
61
+ isError: true,
62
+ };
63
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Sole chokepoint for sanitizing error payloads surfaced to MCP clients.
3
+ * Walks the envelope and replaces values that could leak credentials.
4
+ */
5
+
6
+ const GHOST_KEY_PATTERN = /[0-9a-f]{24}:[0-9a-f]{64}/gi;
7
+ const URL_SECRET_QS_PATTERN = /([?&](?:key|token|access_token)=)[^&\s"']+/gi;
8
+ // Authorization / Set-Cookie values stop at ';' (Set-Cookie attributes like
9
+ // HttpOnly/Secure are not secrets). A bare `Cookie` header can contain multiple
10
+ // `name=value` pairs separated by ';' — all of which may be session tokens — so
11
+ // it gets a separate, greedier pattern that stops only at end-of-line.
12
+ const AUTH_HEADER_PATTERN = /(Authorization|Set-Cookie)\s*[:=]\s*[^\r\n,;]+/gi;
13
+ // Negative look-behind so this pattern matches a bare `Cookie:` header but not
14
+ // the `Cookie` substring inside `Set-Cookie:` (handled by AUTH_HEADER_PATTERN).
15
+ const COOKIE_HEADER_PATTERN = /(?<!Set-)(Cookie)\s*[:=]\s*[^\r\n]+/gi;
16
+ const REDACTED = '[REDACTED]';
17
+ const ORIGINAL_MESSAGE_MAX_BYTES = 2048;
18
+
19
+ function redactString(value, envKey) {
20
+ if (typeof value !== 'string' || value.length === 0) return value;
21
+ let out = value;
22
+ if (envKey) {
23
+ out = out.replaceAll(envKey, REDACTED);
24
+ }
25
+ out = out.replace(GHOST_KEY_PATTERN, REDACTED);
26
+ out = out.replace(URL_SECRET_QS_PATTERN, `$1${REDACTED}`);
27
+ out = out.replace(AUTH_HEADER_PATTERN, `$1: ${REDACTED}`);
28
+ out = out.replace(COOKIE_HEADER_PATTERN, `$1: ${REDACTED}`);
29
+ return out;
30
+ }
31
+
32
+ function truncate(value, maxBytes) {
33
+ if (typeof value !== 'string') return value;
34
+ if (Buffer.byteLength(value, 'utf8') <= maxBytes) return value;
35
+ // Slice by bytes (not chars) to honour the byte limit even for multibyte content.
36
+ return `${Buffer.from(value, 'utf8').subarray(0, maxBytes).toString('utf8')}…[truncated]`;
37
+ }
38
+
39
+ function walk(node, envKey) {
40
+ if (node === null || node === undefined) return node;
41
+ if (typeof node === 'string') return redactString(node, envKey);
42
+ if (Array.isArray(node)) return node.map((item) => walk(item, envKey));
43
+ if (typeof node === 'object') {
44
+ const result = {};
45
+ for (const [k, v] of Object.entries(node)) {
46
+ const walked = walk(v, envKey);
47
+ result[k] =
48
+ k === 'originalMessage' && typeof walked === 'string'
49
+ ? truncate(walked, ORIGINAL_MESSAGE_MAX_BYTES)
50
+ : walked;
51
+ }
52
+ return result;
53
+ }
54
+ return node;
55
+ }
56
+
57
+ /**
58
+ * Deep-walks an error envelope and redacts known secret patterns.
59
+ * Non-destructive: returns a new object; does not mutate input.
60
+ *
61
+ * @param {object} envelope - Error envelope object.
62
+ * @returns {object} Sanitized envelope.
63
+ */
64
+ export function sanitizeErrorPayload(envelope) {
65
+ const envKey = process.env.GHOST_ADMIN_API_KEY || '';
66
+ return walk(envelope, envKey);
67
+ }
@@ -3,6 +3,7 @@
3
3
  * Provides explicit Zod validation to ensure input is validated at handler entry points.
4
4
  */
5
5
  import { ValidationError } from '../errors/index.js';
6
+ import { formatErrorResponse } from './formatErrorResponse.js';
6
7
 
7
8
  /**
8
9
  * Validates tool input against a Zod schema and returns a structured result.
@@ -18,10 +19,7 @@ export const validateToolInput = (schema, input, toolName) => {
18
19
  const error = ValidationError.fromZod(result.error, toolName);
19
20
  return {
20
21
  success: false,
21
- errorResponse: {
22
- content: [{ type: 'text', text: JSON.stringify(error.toJSON(), null, 2) }],
23
- isError: true,
24
- },
22
+ errorResponse: formatErrorResponse(error, toolName),
25
23
  };
26
24
  }
27
25
  return { success: true, data: result.data };