mcp-http-webhook 1.0.5 → 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,323 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from '@jest/globals';
2
+ import request from 'supertest';
3
+ import { createMCPServer, InMemoryStore, ResourceDefinition, AuthContext, ResourceReadOptions } from '../index';
4
+
5
+ describe('Pagination Support', () => {
6
+ let server: any;
7
+ let app: any;
8
+
9
+ // Mock data
10
+ const mockItems = Array.from({ length: 50 }, (_, i) => ({
11
+ id: `item-${i + 1}`,
12
+ name: `Item ${i + 1}`,
13
+ data: `Data for item ${i + 1}`,
14
+ }));
15
+
16
+ beforeAll(async () => {
17
+ const paginatedResource: ResourceDefinition = {
18
+ uri: 'test://items',
19
+ name: 'test-items',
20
+ description: 'Test resource with pagination',
21
+ mimeType: 'application/json',
22
+
23
+ list: async (context: AuthContext, options?: ResourceReadOptions) => {
24
+ const page = options?.pagination?.page || 1;
25
+ const limit = options?.pagination?.limit || 10;
26
+ const offset = (page - 1) * limit;
27
+
28
+ const paginatedItems = mockItems.slice(offset, offset + limit);
29
+
30
+ return {
31
+ resources: paginatedItems.map(item => ({
32
+ uri: `test://items/${item.id}`,
33
+ name: item.name,
34
+ })),
35
+ pagination: {
36
+ page,
37
+ limit,
38
+ total: mockItems.length,
39
+ hasMore: offset + limit < mockItems.length,
40
+ },
41
+ };
42
+ },
43
+
44
+ read: async (uri: string, context: AuthContext, options?: ResourceReadOptions) => {
45
+ const itemId = uri.split('/').pop();
46
+ const item = mockItems.find(i => i.id === itemId);
47
+
48
+ if (!item) {
49
+ throw new Error('Item not found');
50
+ }
51
+
52
+ // Support pagination for large content
53
+ const page = options?.pagination?.page || 1;
54
+ const limit = options?.pagination?.limit || 100;
55
+
56
+ return {
57
+ contents: item,
58
+ pagination: options?.pagination
59
+ ? {
60
+ page,
61
+ limit,
62
+ total: 1,
63
+ hasMore: false,
64
+ }
65
+ : undefined,
66
+ };
67
+ },
68
+ };
69
+
70
+ const cursorResource: ResourceDefinition = {
71
+ uri: 'test://cursor',
72
+ name: 'test-cursor',
73
+ description: 'Test resource with cursor pagination',
74
+ mimeType: 'application/json',
75
+
76
+ list: async (_context: AuthContext) => {
77
+ return {
78
+ resources: [{ uri: 'test://cursor', name: 'Cursor Resource' }],
79
+ };
80
+ },
81
+
82
+ read: async (uri: string, context: AuthContext, options?: ResourceReadOptions) => {
83
+ const cursor = options?.pagination?.cursor;
84
+ const limit = options?.pagination?.limit || 10;
85
+ const cursorIndex = cursor ? parseInt(cursor, 10) : 0;
86
+
87
+ const paginatedItems = mockItems.slice(cursorIndex, cursorIndex + limit);
88
+ const hasMore = cursorIndex + limit < mockItems.length;
89
+
90
+ return {
91
+ contents: paginatedItems,
92
+ pagination: {
93
+ page: 1,
94
+ limit,
95
+ total: mockItems.length,
96
+ hasMore,
97
+ nextCursor: hasMore ? String(cursorIndex + limit) : undefined,
98
+ prevCursor: cursorIndex > 0 ? String(Math.max(0, cursorIndex - limit)) : undefined,
99
+ },
100
+ };
101
+ },
102
+ };
103
+
104
+ server = createMCPServer({
105
+ name: 'pagination-test',
106
+ version: '1.0.0',
107
+ publicUrl: 'http://localhost:3001',
108
+ port: 3001,
109
+ store: new InMemoryStore(),
110
+ tools: [],
111
+ resources: [paginatedResource, cursorResource],
112
+ });
113
+
114
+ await server.start();
115
+ app = server.getApp();
116
+ });
117
+
118
+ afterAll(async () => {
119
+ await server.stop();
120
+ });
121
+
122
+ describe('List with Pagination', () => {
123
+ it('should return first page by default', async () => {
124
+ const response = await request(app)
125
+ .post('/mcp')
126
+ .send({
127
+ jsonrpc: '2.0',
128
+ id: 1,
129
+ method: 'resources/list',
130
+ params: {},
131
+ });
132
+
133
+ expect(response.status).toBe(200);
134
+ expect(response.body.result).toBeDefined();
135
+ expect(response.body.result.resources).toHaveLength(10); // Default limit
136
+ expect(response.body.result._meta?.pagination).toMatchObject({
137
+ page: 1,
138
+ limit: 10,
139
+ total: 50,
140
+ hasMore: true,
141
+ });
142
+ });
143
+
144
+ it('should return specific page with custom limit', async () => {
145
+ const response = await request(app)
146
+ .post('/mcp')
147
+ .send({
148
+ jsonrpc: '2.0',
149
+ id: 1,
150
+ method: 'resources/list',
151
+ params: {},
152
+ _meta: {
153
+ page: 2,
154
+ limit: 5,
155
+ },
156
+ });
157
+
158
+ expect(response.status).toBe(200);
159
+ expect(response.body.result.resources).toHaveLength(5);
160
+ expect(response.body.result._meta?.pagination).toMatchObject({
161
+ page: 2,
162
+ limit: 5,
163
+ total: 50,
164
+ hasMore: true,
165
+ });
166
+ });
167
+
168
+ it('should indicate no more pages on last page', async () => {
169
+ const response = await request(app)
170
+ .post('/mcp')
171
+ .send({
172
+ jsonrpc: '2.0',
173
+ id: 1,
174
+ method: 'resources/list',
175
+ params: {},
176
+ _meta: {
177
+ page: 5,
178
+ limit: 10,
179
+ },
180
+ });
181
+
182
+ expect(response.status).toBe(200);
183
+ expect(response.body.result.resources).toHaveLength(10);
184
+ expect(response.body.result._meta?.pagination.hasMore).toBe(false);
185
+ });
186
+ });
187
+
188
+ describe('Read with Pagination', () => {
189
+ it('should read without pagination by default', async () => {
190
+ const response = await request(app)
191
+ .post('/mcp')
192
+ .send({
193
+ jsonrpc: '2.0',
194
+ id: 1,
195
+ method: 'resources/read',
196
+ params: {
197
+ uri: 'test://items/item-1',
198
+ },
199
+ });
200
+
201
+ expect(response.status).toBe(200);
202
+ expect(response.body.result.contents).toBeDefined();
203
+ expect(response.body.result._meta?.pagination).toBeUndefined();
204
+ });
205
+
206
+ it('should support pagination when requested', async () => {
207
+ const response = await request(app)
208
+ .post('/mcp')
209
+ .send({
210
+ jsonrpc: '2.0',
211
+ id: 1,
212
+ method: 'resources/read',
213
+ params: {
214
+ uri: 'test://items/item-1',
215
+ },
216
+ _meta: {
217
+ page: 1,
218
+ limit: 50,
219
+ },
220
+ });
221
+
222
+ expect(response.status).toBe(200);
223
+ expect(response.body.result.contents).toBeDefined();
224
+ expect(response.body.result._meta?.pagination).toMatchObject({
225
+ page: 1,
226
+ limit: 50,
227
+ total: 1,
228
+ hasMore: false,
229
+ });
230
+ });
231
+ });
232
+
233
+ describe('Cursor-based Pagination', () => {
234
+ it('should support cursor pagination', async () => {
235
+ const response = await request(app)
236
+ .post('/mcp')
237
+ .send({
238
+ jsonrpc: '2.0',
239
+ id: 1,
240
+ method: 'resources/read',
241
+ params: {
242
+ uri: 'test://cursor',
243
+ },
244
+ _meta: {
245
+ limit: 10,
246
+ },
247
+ });
248
+
249
+ expect(response.status).toBe(200);
250
+ expect(response.body.result.contents).toHaveLength(10);
251
+ expect(response.body.result._meta?.pagination).toMatchObject({
252
+ page: 1,
253
+ limit: 10,
254
+ total: 50,
255
+ hasMore: true,
256
+ });
257
+ expect(response.body.result._meta?.pagination.nextCursor).toBe('10');
258
+ expect(response.body.result._meta?.pagination.prevCursor).toBeUndefined();
259
+ });
260
+
261
+ it('should navigate using cursor', async () => {
262
+ const response = await request(app)
263
+ .post('/mcp')
264
+ .send({
265
+ jsonrpc: '2.0',
266
+ id: 1,
267
+ method: 'resources/read',
268
+ params: {
269
+ uri: 'test://cursor',
270
+ },
271
+ _meta: {
272
+ cursor: '20',
273
+ limit: 10,
274
+ },
275
+ });
276
+
277
+ expect(response.status).toBe(200);
278
+ expect(response.body.result.contents).toHaveLength(10);
279
+ expect(response.body.result._meta?.pagination.nextCursor).toBe('30');
280
+ expect(response.body.result._meta?.pagination.prevCursor).toBe('10');
281
+ });
282
+
283
+ it('should handle last page with cursor', async () => {
284
+ const response = await request(app)
285
+ .post('/mcp')
286
+ .send({
287
+ jsonrpc: '2.0',
288
+ id: 1,
289
+ method: 'resources/read',
290
+ params: {
291
+ uri: 'test://cursor',
292
+ },
293
+ _meta: {
294
+ cursor: '45',
295
+ limit: 10,
296
+ },
297
+ });
298
+
299
+ expect(response.status).toBe(200);
300
+ expect(response.body.result.contents).toHaveLength(5);
301
+ expect(response.body.result._meta?.pagination.hasMore).toBe(false);
302
+ expect(response.body.result._meta?.pagination.nextCursor).toBeUndefined();
303
+ expect(response.body.result._meta?.pagination.prevCursor).toBe('35');
304
+ });
305
+ });
306
+
307
+ describe('Backward Compatibility', () => {
308
+ it('should handle resources without pagination support', async () => {
309
+ // This tests that old resources still work
310
+ const response = await request(app)
311
+ .post('/mcp')
312
+ .send({
313
+ jsonrpc: '2.0',
314
+ id: 1,
315
+ method: 'resources/list',
316
+ params: {},
317
+ });
318
+
319
+ expect(response.status).toBe(200);
320
+ expect(response.body.result.resources).toBeDefined();
321
+ });
322
+ });
323
+ });
package/src/server.ts CHANGED
@@ -2,7 +2,8 @@ import express, { Express, Request, Response, NextFunction } from 'express';
2
2
  import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
3
3
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
4
4
  import { z } from 'zod';
5
- import { MCPServerConfig, MCPServer, Logger, JSONSchema } from './types';
5
+ import { completable } from '@modelcontextprotocol/sdk/server/completable.js';
6
+ import { MCPServerConfig, MCPServer, Logger, JSONSchema, CredentialFieldDefinition } from './types';
6
7
  import { SubscriptionManager } from './subscriptions/SubscriptionManager';
7
8
  import { WebhookManager } from './webhooks/WebhookManager';
8
9
  import { MCPError, AuthenticationError, ValidationError } from './errors';
@@ -69,6 +70,101 @@ function convertToZodSchema(jsonSchema: JSONSchema): Record<string, z.ZodTypeAny
69
70
  return schema;
70
71
  }
71
72
 
73
+ const VALID_CREDENTIAL_FIELD_TYPES = new Set([
74
+ 'string',
75
+ 'number',
76
+ 'boolean',
77
+ 'object',
78
+ 'array',
79
+ 'date',
80
+ 'file',
81
+ 'binary',
82
+ ]);
83
+
84
+ function sanitizeCredentialFields(
85
+ fields: unknown,
86
+ path = 'credentials'
87
+ ): CredentialFieldDefinition[] {
88
+ if (fields === undefined || fields === null) {
89
+ return [];
90
+ }
91
+
92
+ if (!Array.isArray(fields)) {
93
+ throw new ValidationError(`MCP configuration ${path} must be an array`);
94
+ }
95
+
96
+ return fields.map((field, index) =>
97
+ sanitizeCredentialField(field, `${path}[${index}]`)
98
+ );
99
+ }
100
+
101
+ function sanitizeCredentialField(
102
+ field: unknown,
103
+ path: string
104
+ ): CredentialFieldDefinition {
105
+ if (typeof field !== 'object' || field === null) {
106
+ throw new ValidationError(`MCP configuration ${path} must be an object`);
107
+ }
108
+
109
+ const candidate = field as Record<string, any>;
110
+ const rawName = candidate.name;
111
+ const name = typeof rawName === 'string' ? rawName.trim() : '';
112
+
113
+ if (!name) {
114
+ throw new ValidationError(`MCP configuration ${path}.name is required`);
115
+ }
116
+
117
+ const rawType = typeof candidate.type === 'string'
118
+ ? candidate.type.toLowerCase()
119
+ : 'string';
120
+ const type = (VALID_CREDENTIAL_FIELD_TYPES.has(rawType)
121
+ ? rawType
122
+ : 'string') as CredentialFieldDefinition['type'];
123
+
124
+ const description =
125
+ typeof candidate.description === 'string' && candidate.description.trim().length > 0
126
+ ? candidate.description
127
+ : undefined;
128
+
129
+ const sanitized: CredentialFieldDefinition = {
130
+ name,
131
+ type,
132
+ required: Boolean(candidate.required),
133
+ };
134
+
135
+ if (description) {
136
+ sanitized.description = description;
137
+ }
138
+
139
+ if (candidate.default !== undefined) {
140
+ sanitized.default = candidate.default;
141
+ }
142
+
143
+ if (Array.isArray(candidate.innerFields) && candidate.innerFields.length > 0) {
144
+ const inner = sanitizeCredentialFields(candidate.innerFields, `${path}.innerFields`);
145
+ if (inner.length > 0) {
146
+ sanitized.innerFields = inner;
147
+ }
148
+ }
149
+
150
+ if (Array.isArray(candidate.or) && candidate.or.length > 0) {
151
+ const alternatives = sanitizeCredentialFields(candidate.or, `${path}.or`);
152
+ if (alternatives.length > 0) {
153
+ sanitized.or = alternatives;
154
+ }
155
+ }
156
+
157
+ if (candidate.config && typeof candidate.config === 'object') {
158
+ sanitized.config = { ...candidate.config };
159
+ }
160
+
161
+ if (candidate.metadata && typeof candidate.metadata === 'object') {
162
+ sanitized.metadata = { ...candidate.metadata };
163
+ }
164
+
165
+ return sanitized;
166
+ }
167
+
72
168
  /**
73
169
  * Create MCP HTTP Webhook Server using standard MCP SDK
74
170
  */
@@ -80,6 +176,8 @@ export function createMCPServer(config: MCPServerConfig): MCPServer {
80
176
 
81
177
  // Validate configuration
82
178
  validateConfig(config);
179
+ // Validate credential metadata upfront to surface configuration issues early
180
+ sanitizeCredentialFields(config.credentials);
83
181
 
84
182
  // Create Express app
85
183
  const app: Express = express();
@@ -108,6 +206,7 @@ export function createMCPServer(config: MCPServerConfig): MCPServer {
108
206
  experimental: {
109
207
  isConnector: true,
110
208
  },
209
+ "completions": {}
111
210
  },
112
211
  }
113
212
  );
@@ -158,12 +257,28 @@ export function createMCPServer(config: MCPServerConfig): MCPServer {
158
257
  if (isTemplate && resourceDef.list) {
159
258
  // Use ResourceTemplate for templated URIs
160
259
  const template = new ResourceTemplate(resourceDef.uri, {
161
- list: async () => {
260
+ list: async (extra?: any) => {
162
261
  const context = (sdkServer as any)._currentContext || { userId: 'anonymous' };
163
- const resources = await resourceDef.list!(context);
262
+
263
+ // Extract pagination options from extra metadata
264
+ const options: any = {};
265
+ const meta = extra?._meta as any;
266
+ if (meta?.page || meta?.limit || meta?.cursor) {
267
+ options.pagination = {
268
+ page: meta.page ? parseInt(String(meta.page), 10) : undefined,
269
+ limit: meta.limit ? parseInt(String(meta.limit), 10) : undefined,
270
+ cursor: meta.cursor,
271
+ };
272
+ }
273
+
274
+ const result = await resourceDef.list!(context, options);
275
+
276
+ // Handle both array and paginated responses
277
+ const resources = Array.isArray(result) ? result : result.resources;
278
+ const pagination = Array.isArray(result) ? undefined : result.pagination;
164
279
 
165
280
  // Map to MCP SDK Resource format with index signature
166
- return {
281
+ const response: any = {
167
282
  resources: resources.map((r: any) => ({
168
283
  uri: r.uri,
169
284
  name: r.name,
@@ -173,6 +288,13 @@ export function createMCPServer(config: MCPServerConfig): MCPServer {
173
288
  ...r, // Spread to add index signature
174
289
  })),
175
290
  };
291
+
292
+ // Add pagination metadata if present
293
+ if (pagination) {
294
+ response._meta = { pagination };
295
+ }
296
+
297
+ return response;
176
298
  },
177
299
  });
178
300
  sdkServer.registerResource(
@@ -182,9 +304,21 @@ export function createMCPServer(config: MCPServerConfig): MCPServer {
182
304
  description: resourceDef.description,
183
305
  mimeType: resourceDef.mimeType || 'application/json',
184
306
  },
185
- async (uri: any, _variables: any, _extra: any) => {
307
+ async (uri, _variables, _extra) => {
186
308
  const context = (sdkServer as any)._currentContext || { userId: 'anonymous' };
187
- const result = await resourceDef.read(uri.href, { ...context, ...(_variables || {}) });
309
+
310
+ // Extract pagination options from _extra metadata
311
+ const options: any = {};
312
+ const meta = _extra?._meta as any;
313
+ if (meta?.page || meta?.limit || meta?.cursor) {
314
+ options.pagination = {
315
+ page: meta.page ? parseInt(String(meta.page), 10) : undefined,
316
+ limit: meta.limit ? parseInt(String(meta.limit), 10) : undefined,
317
+ cursor: meta.cursor,
318
+ };
319
+ }
320
+
321
+ const result = await resourceDef.read(uri.href, { ...context, ...(_variables || {}) }, options);
188
322
 
189
323
  const metadata =
190
324
  result.metadata ||
@@ -214,6 +348,11 @@ export function createMCPServer(config: MCPServerConfig): MCPServer {
214
348
  if (metadata) {
215
349
  response.metadata = metadata;
216
350
  }
351
+
352
+ // Add pagination metadata if present
353
+ if (result.pagination) {
354
+ response._meta = { pagination: result.pagination };
355
+ }
217
356
 
218
357
  return response;
219
358
  }
@@ -229,7 +368,19 @@ export function createMCPServer(config: MCPServerConfig): MCPServer {
229
368
  },
230
369
  async (uri: any, _extra: any) => {
231
370
  const context = (sdkServer as any)._currentContext || { userId: 'anonymous' };
232
- const result = await resourceDef.read(uri.href, context);
371
+
372
+ // Extract pagination options from _extra metadata
373
+ const options: any = {};
374
+ const meta = _extra?._meta as any;
375
+ if (meta?.page || meta?.limit || meta?.cursor) {
376
+ options.pagination = {
377
+ page: meta.page ? parseInt(String(meta.page), 10) : undefined,
378
+ limit: meta.limit ? parseInt(String(meta.limit), 10) : undefined,
379
+ cursor: meta.cursor,
380
+ };
381
+ }
382
+
383
+ const result = await resourceDef.read(uri.href, context, options);
233
384
 
234
385
  const metadata =
235
386
  result.metadata ||
@@ -259,6 +410,11 @@ export function createMCPServer(config: MCPServerConfig): MCPServer {
259
410
  if (metadata) {
260
411
  response.metadata = metadata;
261
412
  }
413
+
414
+ // Add pagination metadata if present
415
+ if (result.pagination) {
416
+ response._meta = { pagination: result.pagination };
417
+ }
262
418
 
263
419
  return response;
264
420
  }
@@ -269,19 +425,43 @@ export function createMCPServer(config: MCPServerConfig): MCPServer {
269
425
  // Register prompts with MCP SDK
270
426
  if (config.prompts) {
271
427
  config.prompts.forEach((promptDef) => {
428
+ const argsSchema: Record<string, any> = {};
429
+
430
+ // Build args schema with completion support
431
+ if (promptDef.arguments) {
432
+ promptDef.arguments.forEach((arg) => {
433
+ let argSchema = z.string();
434
+
435
+ // Add completion support if handler is provided
436
+ if (promptDef.completion) {
437
+ argSchema = completable(z.string(), async (value, context) => {
438
+ const ctx = (sdkServer as any)._currentContext || { userId: 'anonymous' };
439
+ const ref = {
440
+ type: 'ref/prompt' as const,
441
+ name: promptDef.name,
442
+ arguments: context?.arguments || {},
443
+ };
444
+
445
+ try {
446
+ const completions = await promptDef.completion!(ref, arg.name, ctx);
447
+ return completions.map(c => c.value);
448
+ } catch (error) {
449
+ logger.error('Completion error', { error });
450
+ return [];
451
+ }
452
+ }) as any;
453
+ }
454
+
455
+ argsSchema[arg.name] = argSchema;
456
+ });
457
+ }
458
+
272
459
  sdkServer.registerPrompt(
273
460
  promptDef.name,
274
461
  {
275
462
  title: promptDef.name,
276
463
  description: promptDef.description,
277
- argsSchema:
278
- promptDef.arguments?.reduce(
279
- (schema, arg) => {
280
- schema[arg.name] = z.string();
281
- return schema;
282
- },
283
- {} as Record<string, any>
284
- ) || {},
464
+ argsSchema,
285
465
  },
286
466
  async (args: any) => {
287
467
  const context = (sdkServer as any)._currentContext || { userId: 'anonymous' };
@@ -481,6 +661,14 @@ export function createMCPServer(config: MCPServerConfig): MCPServer {
481
661
  })
482
662
  );
483
663
 
664
+ app.get(`${basePath}/.well-known/credentials`, asyncHandler(async (req, res) => {
665
+ const credentials = sanitizeCredentialFields(config.credentials);
666
+ res.json({
667
+ version: config.version,
668
+ credentials,
669
+ });
670
+ }));
671
+
484
672
  // Webhook endpoints
485
673
  const webhookPath = config.webhooks?.incomingPath || '/webhooks/incoming';
486
674