@pikku/core 0.12.71 → 0.12.72

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.
@@ -1,11 +1,12 @@
1
1
  import { describe, test, beforeEach } from 'node:test'
2
2
  import assert from 'node:assert'
3
3
  import { handleHTTPError } from './handle-error.js'
4
- import { addError } from './errors/error-handler.js'
4
+ import { addError, PikkuError } from './errors/error-handler.js'
5
5
  import {
6
6
  NotFoundError,
7
7
  BadRequestError,
8
8
  ForbiddenError,
9
+ MissingScopeError,
9
10
  } from './errors/errors.js'
10
11
  import { resetPikkuState } from './pikku-state.js'
11
12
 
@@ -399,6 +400,112 @@ describe('handleHTTPError', () => {
399
400
  assert.deepStrictEqual(http._state.jsonBody, { errorId: 'tracker-str' })
400
401
  })
401
402
 
403
+ test('should not leak the payload of a registered server error', () => {
404
+ class LeakyInternalError extends PikkuError {}
405
+ addError(LeakyInternalError, {
406
+ status: 500,
407
+ message: 'Something went wrong',
408
+ })
409
+
410
+ const logger = createMockLogger()
411
+ const http = createMockHTTP()
412
+ const error = new LeakyInternalError(
413
+ 'connection to 10.0.0.1 refused'
414
+ ) as any
415
+ error.payload = { connectionString: 'postgres://user:hunter2@10.0.0.1' }
416
+
417
+ handleHTTPError(
418
+ error,
419
+ http as any,
420
+ 'tracker-leak',
421
+ logger as any,
422
+ [],
423
+ true,
424
+ false
425
+ )
426
+
427
+ assert.strictEqual(http._state.statusCode, 500)
428
+ assert.strictEqual(http._state.jsonBody.payload, undefined)
429
+ })
430
+
431
+ test('should not leak the raw message of a registered server error', () => {
432
+ class InternalMessageError extends PikkuError {}
433
+ addError(InternalMessageError, {
434
+ status: 500,
435
+ message: 'Something went wrong',
436
+ })
437
+
438
+ const logger = createMockLogger()
439
+ const http = createMockHTTP()
440
+ const error = new InternalMessageError('secret internal detail')
441
+
442
+ handleHTTPError(
443
+ error,
444
+ http as any,
445
+ 'tracker-msg',
446
+ logger as any,
447
+ [],
448
+ true,
449
+ false
450
+ )
451
+
452
+ assert.strictEqual(http._state.statusCode, 500)
453
+ assert.strictEqual(http._state.jsonBody.message, 'Something went wrong')
454
+ })
455
+
456
+ test('should keep the payload of a client facing error', () => {
457
+ const logger = createMockLogger()
458
+ const http = createMockHTTP()
459
+ const error = new MissingScopeError('admin:write')
460
+
461
+ handleHTTPError(
462
+ error,
463
+ http as any,
464
+ 'tracker-scope',
465
+ logger as any,
466
+ [],
467
+ true,
468
+ false
469
+ )
470
+
471
+ assert.strictEqual(http._state.statusCode, 403)
472
+ assert.deepStrictEqual(http._state.jsonBody.payload, {
473
+ error: 'missing_scope',
474
+ scope: 'admin:write',
475
+ })
476
+ assert.strictEqual(
477
+ http._state.jsonBody.message,
478
+ 'Missing required scope: admin:write'
479
+ )
480
+ })
481
+
482
+ test('should expose server error details when exposeErrors is true', () => {
483
+ class ExposableInternalError extends PikkuError {}
484
+ addError(ExposableInternalError, {
485
+ status: 500,
486
+ message: 'Something went wrong',
487
+ })
488
+
489
+ const logger = createMockLogger()
490
+ const http = createMockHTTP()
491
+ const error = new ExposableInternalError('internal detail') as any
492
+ error.payload = { debug: true }
493
+
494
+ handleHTTPError(
495
+ error,
496
+ http as any,
497
+ 'tracker-expose-known',
498
+ logger as any,
499
+ [],
500
+ true,
501
+ false,
502
+ true
503
+ )
504
+
505
+ assert.strictEqual(http._state.jsonBody.message, 'internal detail')
506
+ assert.deepStrictEqual(http._state.jsonBody.payload, { debug: true })
507
+ })
508
+
402
509
  test('should handle warning log without trackerId', () => {
403
510
  const logger = createMockLogger()
404
511
  const http = createMockHTTP()
@@ -14,6 +14,8 @@ import type { PikkuHTTP } from './wirings/http/http.types.js'
14
14
  * @param {number[]} logWarningsForStatusCodes - HTTP status codes to log as warnings
15
15
  * @param {boolean} respondWith404 - Whether to respond with 404 for NotFoundError
16
16
  * @param {boolean} bubbleError - Whether to throw the error after handling
17
+ * @param {boolean} exposeErrors - Whether to include internal error details (message, stack and
18
+ * payload of 5xx errors) in the response body. Ignored in production.
17
19
  */
18
20
  export const handleHTTPError = (
19
21
  e: any,
@@ -33,15 +35,21 @@ export const handleHTTPError = (
33
35
  // Get appropriate error response
34
36
  const errorResponse = getErrorResponse(e)
35
37
  if (errorResponse != null) {
38
+ const clientFacing =
39
+ errorResponse.status < 500 || (exposeErrors && !isProduction())
40
+
36
41
  // Set status and response body
37
42
  http?.response?.status(errorResponse.status)
38
43
  http?.response?.json({
39
44
  name: e instanceof Error ? e.name : undefined,
40
45
  message:
41
- e instanceof Error && e.message && e.message !== 'An error occurred'
46
+ clientFacing &&
47
+ e instanceof Error &&
48
+ e.message &&
49
+ e.message !== 'An error occurred'
42
50
  ? e.message
43
51
  : errorResponse.message,
44
- payload: (e as any).payload,
52
+ payload: clientFacing ? (e as any).payload : undefined,
45
53
  errorId: traceId,
46
54
  })
47
55
 
@@ -3,6 +3,7 @@ import * as assert from 'assert'
3
3
  import {
4
4
  addSchema,
5
5
  getSchema,
6
+ applyDefaultsFromSchema,
6
7
  coerceTopLevelDataFromSchema,
7
8
  validateSchema,
8
9
  compileAllSchemas,
@@ -111,6 +112,108 @@ describe('Schema', () => {
111
112
  })
112
113
  })
113
114
 
115
+ describe('applyDefaultsFromSchema', () => {
116
+ before(() => {
117
+ addSchema('defaultsSchema', {
118
+ properties: {
119
+ page: { type: 'number', default: 1 },
120
+ limit: { type: 'number', default: 50 },
121
+ addons: { type: 'array', default: [] },
122
+ mode: { type: 'string', default: 'manual' },
123
+ enabled: { type: 'boolean', default: true },
124
+ name: { type: 'string' },
125
+ },
126
+ })
127
+
128
+ addSchema('noDefaultsSchema', {
129
+ properties: { name: { type: 'string' } },
130
+ })
131
+
132
+ addSchema('booleanPropSchema', {
133
+ properties: { isActive: true, page: { type: 'number', default: 1 } },
134
+ })
135
+ })
136
+
137
+ test('should fill in absent properties from their default', () => {
138
+ const data = applyDefaultsFromSchema('defaultsSchema', {})
139
+ assert.strictEqual(data.page, 1)
140
+ assert.strictEqual(data.limit, 50)
141
+ assert.strictEqual(data.mode, 'manual')
142
+ assert.strictEqual(data.enabled, true)
143
+ })
144
+
145
+ test('should not overwrite a supplied value', () => {
146
+ const data = applyDefaultsFromSchema('defaultsSchema', {
147
+ page: 7,
148
+ limit: 200,
149
+ })
150
+ assert.strictEqual(data.page, 7)
151
+ assert.strictEqual(data.limit, 200)
152
+ })
153
+
154
+ // `false` and `0` are the values a truthiness check silently replaces, and
155
+ // the reason this tests presence rather than falsiness.
156
+ test('should not overwrite a supplied falsy value', () => {
157
+ const data = applyDefaultsFromSchema('defaultsSchema', {
158
+ page: 0,
159
+ enabled: false,
160
+ })
161
+ assert.strictEqual(data.page, 0)
162
+ assert.strictEqual(data.enabled, false)
163
+ })
164
+
165
+ test('should fill defaults into nullish data', () => {
166
+ assert.strictEqual(
167
+ applyDefaultsFromSchema('defaultsSchema', undefined).page,
168
+ 1
169
+ )
170
+ assert.strictEqual(
171
+ applyDefaultsFromSchema('defaultsSchema', null).limit,
172
+ 50
173
+ )
174
+ })
175
+
176
+ test('should leave properties without a default absent', () => {
177
+ const data = applyDefaultsFromSchema('defaultsSchema', {})
178
+ assert.ok(!('name' in data))
179
+ })
180
+
181
+ // Two requests sharing one array default would let the first request's
182
+ // pushes show up in the second.
183
+ test('should clone object and array defaults per call', () => {
184
+ const first = applyDefaultsFromSchema('defaultsSchema', {})
185
+ const second = applyDefaultsFromSchema('defaultsSchema', {})
186
+ first.addons.push('leaked')
187
+ assert.deepStrictEqual(second.addons, [])
188
+ })
189
+
190
+ test('should return the same data when the schema has no defaults', () => {
191
+ const data = { name: 'x' }
192
+ assert.strictEqual(
193
+ applyDefaultsFromSchema('noDefaultsSchema', data),
194
+ data
195
+ )
196
+ })
197
+
198
+ test('should return data untouched for an unknown schema', () => {
199
+ const data = { name: 'x' }
200
+ assert.strictEqual(applyDefaultsFromSchema('nonExistent', data), data)
201
+ })
202
+
203
+ test('should leave a primitive body for the validator to reject', () => {
204
+ assert.strictEqual(
205
+ applyDefaultsFromSchema('defaultsSchema', 'nope'),
206
+ 'nope'
207
+ )
208
+ })
209
+
210
+ test('should skip boolean schema properties', () => {
211
+ const data = applyDefaultsFromSchema('booleanPropSchema', {})
212
+ assert.strictEqual(data.page, 1)
213
+ assert.ok(!('isActive' in data))
214
+ })
215
+ })
216
+
114
217
  describe('validateSchema', () => {
115
218
  beforeEach(() => {
116
219
  resetPikkuState()
package/src/schema.ts CHANGED
@@ -94,6 +94,57 @@ const validateAllSchemasLoaded = (
94
94
  }
95
95
  }
96
96
 
97
+ /**
98
+ * Fill in absent top-level properties from their schema `default`.
99
+ *
100
+ * A `default` reaches the generated JSON Schema and keeps the property out of
101
+ * `required`, so omitting it validates — but nothing was ever filling it in.
102
+ * JSON Schema validators are pure by specification and none of the ones Pikku
103
+ * ships with (`@cfworker/json-schema`, and Ajv unless `useDefaults` is set)
104
+ * annotate the instance, so the function received `undefined` for a property
105
+ * its generated type declares as present. That is the worst shape a mismatch
106
+ * can take: validation permits the omission, the type says the value is there,
107
+ * and the body reads `undefined`.
108
+ *
109
+ * Applied unconditionally rather than alongside `coerceTopLevelDataFromSchema`,
110
+ * whose `coerceDataFromSchema` flag is about decoding transport-encoded values
111
+ * (a query string's `"1,2"` into an array). Defaults are a property of the
112
+ * schema, not of how the call arrived, so gating them on that flag would apply
113
+ * them over HTTP and skip them on a direct RPC invocation.
114
+ *
115
+ * Returns the data to use, which is a new object only when defaults had to be
116
+ * added to a nullish input — a call made with no arguments at all still gets
117
+ * them. Values are cloned so an object or array default (`[]`, `{}`) is never
118
+ * shared as one mutable instance across every request.
119
+ */
120
+ export const applyDefaultsFromSchema = (
121
+ schemaName: string,
122
+ data: any,
123
+ packageName: string | null = null
124
+ ) => {
125
+ const schema = pikkuState(packageName, 'misc', 'schemas').get(schemaName)
126
+ if (!schema?.properties) return data
127
+
128
+ // A primitive body cannot carry named properties; leave it for the validator
129
+ // to reject rather than reshaping it into something that would pass.
130
+ if (data != null && typeof data !== 'object') return data
131
+
132
+ let result = data
133
+ for (const key in schema.properties) {
134
+ const property = schema.properties[key]
135
+ if (typeof property === 'boolean' || !('default' in property)) {
136
+ continue
137
+ }
138
+ // Allocated only once a default is actually found, so a schema without any
139
+ // leaves the caller's data (and its absence) exactly as it was.
140
+ result ??= {}
141
+ if (result[key] === undefined) {
142
+ result[key] = structuredClone(property.default)
143
+ }
144
+ }
145
+ return result
146
+ }
147
+
97
148
  export const coerceTopLevelDataFromSchema = (
98
149
  schemaName: string,
99
150
  data: any,
@@ -531,6 +531,7 @@ export const fetchData = async <In, Out>(
531
531
  exposeErrors = !isProduction(),
532
532
  generateRequestId,
533
533
  traceId: externalTraceId,
534
+ maxBodySize,
534
535
  }: RunHTTPWiringOptions = {}
535
536
  ): Promise<Out | void> => {
536
537
  const singletonServices = getSingletonServices()
@@ -540,7 +541,9 @@ export const fetchData = async <In, Out>(
540
541
 
541
542
  // Combine the request and response into one wire object
542
543
  const pikkuRequest =
543
- request instanceof Request ? new PikkuFetchHTTPRequest(request) : request
544
+ request instanceof Request
545
+ ? new PikkuFetchHTTPRequest(request, { maxBodySize })
546
+ : request
544
547
 
545
548
  // Resolve traceId: external (e.g. CF-Ray) > x-request-id header > generated
546
549
  let requestId: string | null = externalTraceId ?? null
@@ -38,6 +38,8 @@ export type RunHTTPWiringOptions = Partial<{
38
38
  generateRequestId: () => string
39
39
  /** Pre-resolved trace ID (e.g. CF-Ray). Falls back to x-request-id header or generated ID. */
40
40
  traceId: string
41
+ /** Maximum request body size in bytes, applied when pikku wraps a fetch `Request`. */
42
+ maxBodySize: number
41
43
  }>
42
44
 
43
45
  /**
@@ -1,4 +1,8 @@
1
- export { PikkuFetchHTTPRequest } from './pikku-fetch-http-request.js'
1
+ export {
2
+ PikkuFetchHTTPRequest,
3
+ DEFAULT_MAX_BODY_SIZE,
4
+ } from './pikku-fetch-http-request.js'
5
+ export type { PikkuFetchHTTPRequestOptions } from './pikku-fetch-http-request.js'
2
6
  export { PikkuFetchHTTPResponse } from './pikku-fetch-http-response.js'
3
7
  export { logRoutes } from './log-http-routes.js'
4
8
 
@@ -221,6 +221,95 @@ test('data() throws on boolean conflict', async () => {
221
221
 
222
222
  // --- Safe fallback: only one source
223
223
 
224
+ // --- Body size limits
225
+
226
+ const streamedRequest = (chunks) =>
227
+ new Request('http://localhost', {
228
+ method: 'POST',
229
+ headers: { 'Content-Type': 'application/json' },
230
+ body: new ReadableStream({
231
+ start(controller) {
232
+ for (const chunk of chunks) {
233
+ controller.enqueue(new TextEncoder().encode(chunk))
234
+ }
235
+ controller.close()
236
+ },
237
+ }),
238
+ duplex: 'half',
239
+ })
240
+
241
+ test('arrayBuffer() rejects a body larger than the configured limit', async () => {
242
+ const req = new Request('http://localhost', {
243
+ method: 'POST',
244
+ headers: { 'Content-Type': 'application/octet-stream' },
245
+ body: 'x'.repeat(64),
246
+ })
247
+ const pikkuReq = new PikkuFetchHTTPRequest(req, { maxBodySize: 16 })
248
+ await assert.rejects(async () => await pikkuReq.arrayBuffer(), {
249
+ name: 'PayloadTooLargeError',
250
+ })
251
+ })
252
+
253
+ test('arrayBuffer() rejects an oversized body with no content-length header', async () => {
254
+ const req = streamedRequest(['x'.repeat(32), 'y'.repeat(32)])
255
+ assert.equal(req.headers.get('content-length'), null)
256
+ const pikkuReq = new PikkuFetchHTTPRequest(req, { maxBodySize: 16 })
257
+ await assert.rejects(async () => await pikkuReq.arrayBuffer(), {
258
+ name: 'PayloadTooLargeError',
259
+ })
260
+ })
261
+
262
+ test('arrayBuffer() rejects a body whose content-length under-reports its size', async () => {
263
+ const req = new Request('http://localhost', {
264
+ method: 'POST',
265
+ headers: {
266
+ 'Content-Type': 'application/json',
267
+ 'Content-Length': '4',
268
+ },
269
+ body: new ReadableStream({
270
+ start(controller) {
271
+ controller.enqueue(new TextEncoder().encode('z'.repeat(128)))
272
+ controller.close()
273
+ },
274
+ }),
275
+ duplex: 'half',
276
+ })
277
+ const pikkuReq = new PikkuFetchHTTPRequest(req, { maxBodySize: 16 })
278
+ await assert.rejects(async () => await pikkuReq.arrayBuffer(), {
279
+ name: 'PayloadTooLargeError',
280
+ })
281
+ })
282
+
283
+ test('data() surfaces PayloadTooLargeError rather than wrapping it', async () => {
284
+ const req = createRequest(
285
+ 'POST',
286
+ 'http://localhost',
287
+ { padding: 'x'.repeat(256) },
288
+ { 'Content-Type': 'application/json' }
289
+ )
290
+ const pikkuReq = new PikkuFetchHTTPRequest(req, { maxBodySize: 16 })
291
+ await assert.rejects(async () => await pikkuReq.data(), {
292
+ name: 'PayloadTooLargeError',
293
+ })
294
+ })
295
+
296
+ test('data() accepts a body within the configured limit', async () => {
297
+ const req = createRequest(
298
+ 'POST',
299
+ 'http://localhost',
300
+ { ok: true },
301
+ { 'Content-Type': 'application/json' }
302
+ )
303
+ const pikkuReq = new PikkuFetchHTTPRequest(req, { maxBodySize: 1024 })
304
+ assert.deepEqual(await pikkuReq.data(), { ok: true })
305
+ })
306
+
307
+ test('data() accepts a streamed body within the default limit', async () => {
308
+ const req = streamedRequest(['{"ok"', ':true}'])
309
+ const pikkuReq = new PikkuFetchHTTPRequest(req)
310
+ assert.deepEqual(await pikkuReq.data(), { ok: true })
311
+ })
312
+
224
313
  test('data() works when only body has values', async () => {
225
314
  const req = createRequest(
226
315
  'POST',
@@ -1,7 +1,22 @@
1
1
  import { parse as parseQuery } from 'picoquery'
2
2
  import { parse as parseCookie } from 'cookie'
3
3
  import type { HTTPMethod, PikkuHTTPRequest, PikkuQuery } from './http.types.js'
4
- import { UnprocessableContentError } from '../../errors/errors.js'
4
+ import {
5
+ PayloadTooLargeError,
6
+ UnprocessableContentError,
7
+ } from '../../errors/errors.js'
8
+
9
+ /**
10
+ * The largest request body read into memory when no limit is configured. Ample
11
+ * for JSON APIs and typical uploads while keeping a single request's memory
12
+ * footprint bounded.
13
+ */
14
+ export const DEFAULT_MAX_BODY_SIZE = 10 * 1024 * 1024
15
+
16
+ export type PikkuFetchHTTPRequestOptions = Partial<{
17
+ /** Maximum request body size in bytes. Defaults to {@link DEFAULT_MAX_BODY_SIZE}. */
18
+ maxBodySize: number
19
+ }>
5
20
 
6
21
  /**
7
22
  * Abstract class representing a pikku request.
@@ -17,9 +32,14 @@ export class PikkuFetchHTTPRequest<
17
32
  #rawBodyText: string | undefined
18
33
  #rawBodyBuffer: ArrayBuffer | undefined
19
34
  #rawBufferPromise: Promise<ArrayBuffer> | undefined
35
+ #maxBodySize: number
20
36
 
21
- constructor(private request: Request) {
37
+ constructor(
38
+ private request: Request,
39
+ { maxBodySize = DEFAULT_MAX_BODY_SIZE }: PikkuFetchHTTPRequestOptions = {}
40
+ ) {
22
41
  this.#url = new URL(request.url)
42
+ this.#maxBodySize = maxBodySize
23
43
  }
24
44
 
25
45
  public method(): HTTPMethod {
@@ -78,13 +98,72 @@ export class PikkuFetchHTTPRequest<
78
98
  )
79
99
  return this.#rawBufferPromise
80
100
  }
81
- this.#rawBufferPromise = this.request.arrayBuffer().then((buf) => {
101
+ this.#rawBufferPromise = this.#readBoundedBuffer().then((buf) => {
82
102
  this.#rawBodyBuffer = buf
83
103
  return buf
84
104
  })
85
105
  return this.#rawBufferPromise
86
106
  }
87
107
 
108
+ /**
109
+ * Reads the body while refusing to buffer more than `maxBodySize` bytes. The
110
+ * declared `content-length` is rejected up front so an oversized body is never
111
+ * transferred, and the stream is measured as it arrives because that header is
112
+ * both optional and attacker-controlled.
113
+ */
114
+ async #readBoundedBuffer(): Promise<ArrayBuffer> {
115
+ const contentLength = this.request.headers.get('content-length')
116
+ if (contentLength !== null) {
117
+ const declaredSize = Number(contentLength)
118
+ if (Number.isFinite(declaredSize) && declaredSize > this.#maxBodySize) {
119
+ throw this.#payloadTooLarge()
120
+ }
121
+ }
122
+
123
+ const stream = this.request.body
124
+ if (stream === null) {
125
+ const buffer = await this.request.arrayBuffer()
126
+ if (buffer.byteLength > this.#maxBodySize) {
127
+ throw this.#payloadTooLarge()
128
+ }
129
+ return buffer
130
+ }
131
+
132
+ const reader = stream.getReader()
133
+ const chunks: Uint8Array[] = []
134
+ let size = 0
135
+ try {
136
+ while (true) {
137
+ const { done, value } = await reader.read()
138
+ if (done) {
139
+ break
140
+ }
141
+ size += value.byteLength
142
+ if (size > this.#maxBodySize) {
143
+ await reader.cancel()
144
+ throw this.#payloadTooLarge()
145
+ }
146
+ chunks.push(value)
147
+ }
148
+ } finally {
149
+ reader.releaseLock()
150
+ }
151
+
152
+ const body = new Uint8Array(size)
153
+ let offset = 0
154
+ for (const chunk of chunks) {
155
+ body.set(chunk, offset)
156
+ offset += chunk.byteLength
157
+ }
158
+ return body.buffer as ArrayBuffer
159
+ }
160
+
161
+ #payloadTooLarge(): PayloadTooLargeError {
162
+ return new PayloadTooLargeError(
163
+ `Request body exceeds the maximum size of ${this.#maxBodySize} bytes`
164
+ )
165
+ }
166
+
88
167
  async #readRawText(): Promise<string> {
89
168
  if (this.#rawBodyText !== undefined) {
90
169
  return this.#rawBodyText
@@ -214,6 +293,9 @@ export class PikkuFetchHTTPRequest<
214
293
  )
215
294
  }
216
295
  } catch (e) {
296
+ if (e instanceof PayloadTooLargeError) {
297
+ throw e
298
+ }
217
299
  throw new UnprocessableContentError(`Error parsing body: ${e}`)
218
300
  }
219
301
  return body
@@ -329,6 +329,46 @@ describe('runMCPTool', () => {
329
329
  }
330
330
  )
331
331
  })
332
+
333
+ test('withholds the error message and stack from internal MCP errors in production', async () => {
334
+ pikkuState(null, 'mcp', 'toolsMeta').prodBoom = {
335
+ name: 'prodBoom',
336
+ title: 'Prod Boom',
337
+ description: 'Prod Boom',
338
+ pikkuFuncId: 'prodBoomFunc',
339
+ inputSchema: null,
340
+ outputSchema: null,
341
+ } as never
342
+ registerFunction('prodBoomFunc', async () => {
343
+ throw new Error('secret internal detail')
344
+ })
345
+
346
+ const originalNodeEnv = process.env.NODE_ENV
347
+ process.env.NODE_ENV = 'production'
348
+ try {
349
+ await assert.rejects(
350
+ () =>
351
+ runMCPTool(
352
+ { jsonrpc: '2.0', id: 'prod-boom-1', params: {} },
353
+ { mcp: mcpWire as never },
354
+ 'prodBoom'
355
+ ),
356
+ (error: unknown) => {
357
+ assert.ok(error instanceof MCPError)
358
+ assert.equal(error.error.code, -32603)
359
+ assert.equal(error.error.message, 'Internal error')
360
+ assert.equal(error.error.data, undefined)
361
+ return true
362
+ }
363
+ )
364
+ } finally {
365
+ if (originalNodeEnv === undefined) {
366
+ delete process.env.NODE_ENV
367
+ } else {
368
+ process.env.NODE_ENV = originalNodeEnv
369
+ }
370
+ }
371
+ })
332
372
  })
333
373
 
334
374
  describe('runMCPPrompt', () => {
@@ -12,6 +12,7 @@ import type {
12
12
  CorePikkuFunctionSessionless,
13
13
  } from '../../function/functions.types.js'
14
14
  import { getErrorResponse } from '../../errors/error-handler.js'
15
+ import { isProduction } from '../../env.js'
15
16
  import { closeWireServices } from '../../utils.js'
16
17
  import {
17
18
  pikkuState,
@@ -35,6 +36,11 @@ export class MCPError extends Error {
35
36
 
36
37
  export type RunMCPEndpointParams<Tools extends string = any> = {
37
38
  mcp?: PikkuMCP<Tools>
39
+ /**
40
+ * Surface the error message + stack on unexpected internal errors.
41
+ * Defaults to enabled outside of production.
42
+ */
43
+ exposeErrors?: boolean
38
44
  }
39
45
 
40
46
  export type JsonRpcError = {
@@ -187,7 +193,7 @@ async function runMCPPikkuFunc(
187
193
  name: string,
188
194
  mcp: CoreMCPResource | CoreMCPPrompt | undefined,
189
195
  pikkuFuncId: string | undefined,
190
- { mcp: mcpWire }: RunMCPEndpointParams
196
+ { mcp: mcpWire, exposeErrors = !isProduction() }: RunMCPEndpointParams
191
197
  ): Promise<JsonRpcResponse> {
192
198
  const singletonServices = getSingletonServices()
193
199
  const createWireServices = getCreateWireServices()
@@ -289,7 +295,10 @@ async function runMCPPikkuFunc(
289
295
  id: request.id,
290
296
  code: -32603,
291
297
  message: 'Internal error',
292
- data: { message: e.message, stack: e.stack },
298
+ data:
299
+ exposeErrors && !isProduction() && e instanceof Error
300
+ ? { message: e.message, stack: e.stack }
301
+ : undefined,
293
302
  })
294
303
  }
295
304
  } finally {