@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 +1 -1
- package/src/mcp_server.js +34 -42
- package/src/utils/__tests__/formatErrorResponse.test.js +158 -0
- package/src/utils/__tests__/sanitizeErrorPayload.test.js +130 -0
- package/src/utils/__tests__/validation.test.js +13 -7
- package/src/utils/formatErrorResponse.js +63 -0
- package/src/utils/sanitizeErrorPayload.js +67 -0
- package/src/utils/validation.js +2 -4
package/package.json
CHANGED
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
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
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
|
|
58
|
-
|
|
59
|
-
expect(
|
|
60
|
-
|
|
61
|
-
expect(
|
|
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
|
|
145
|
-
|
|
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
|
+
}
|
package/src/utils/validation.js
CHANGED
|
@@ -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 };
|