s3db.js 12.0.0 → 12.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/mcp/entrypoint.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
4
4
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
5
- import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
5
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
6
6
  import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
7
7
  import { S3db, CachePlugin, CostsPlugin } from '../dist/s3db.es.js';
8
8
  import { FilesystemCache } from '../src/plugins/cache/filesystem-cache.class.js';
@@ -10,6 +10,7 @@ import { config } from 'dotenv';
10
10
  import { fileURLToPath } from 'url';
11
11
  import { dirname, join } from 'path';
12
12
  import { readFileSync } from 'fs';
13
+ import express from 'express';
13
14
 
14
15
  // Load environment variables
15
16
  config();
@@ -1109,70 +1110,103 @@ class S3dbMCPServer {
1109
1110
  }
1110
1111
 
1111
1112
  setupTransport() {
1112
- const transport = process.argv.includes('--transport=sse') || process.env.MCP_TRANSPORT === 'sse'
1113
- ? new SSEServerTransport('/sse', process.env.MCP_SERVER_HOST || '0.0.0.0', parseInt(process.env.MCP_SERVER_PORT || '17500'))
1114
- : new StdioServerTransport();
1115
-
1116
- this.server.connect(transport);
1117
-
1118
- // SSE specific setup
1119
- if (transport instanceof SSEServerTransport) {
1120
- const host = process.env.MCP_SERVER_HOST || '0.0.0.0';
1121
- const port = process.env.MCP_SERVER_PORT || '17500';
1122
-
1123
- console.log(`S3DB MCP Server running on http://${host}:${port}/sse`);
1124
-
1125
- // Add health check endpoint for SSE transport
1126
- this.setupHealthCheck(host, port);
1113
+ const useHttp = process.argv.includes('--transport=http') || process.env.MCP_TRANSPORT === 'http';
1114
+
1115
+ if (useHttp) {
1116
+ // Setup Express server for Streamable HTTP transport
1117
+ this.setupHttpTransport();
1118
+ } else {
1119
+ // Use stdio transport (default)
1120
+ const transport = new StdioServerTransport();
1121
+ this.server.connect(transport);
1127
1122
  }
1128
1123
  }
1129
1124
 
1130
- setupHealthCheck(host, port) {
1131
- import('http').then(({ createServer }) => {
1132
- const healthServer = createServer((req, res) => {
1133
- if (req.url === '/health') {
1134
- const healthStatus = {
1135
- status: 'healthy',
1136
- timestamp: new Date().toISOString(),
1137
- uptime: process.uptime(),
1138
- version: SERVER_VERSION,
1139
- database: {
1140
- connected: database ? database.isConnected() : false,
1141
- bucket: database?.bucket || null,
1142
- keyPrefix: database?.keyPrefix || null,
1143
- resourceCount: database ? Object.keys(database.resources || {}).length : 0
1144
- },
1145
- memory: process.memoryUsage(),
1146
- environment: {
1147
- nodeVersion: process.version,
1148
- platform: process.platform,
1149
- transport: 'sse'
1150
- }
1151
- };
1125
+ setupHttpTransport() {
1126
+ const host = process.env.MCP_SERVER_HOST || '0.0.0.0';
1127
+ const port = parseInt(process.env.MCP_SERVER_PORT || '17500');
1128
+
1129
+ const app = express();
1130
+ app.use(express.json());
1131
+
1132
+ // Enable CORS for browser-based clients
1133
+ app.use((req, res, next) => {
1134
+ res.header('Access-Control-Allow-Origin', '*');
1135
+ res.header('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
1136
+ res.header('Access-Control-Allow-Headers', 'Content-Type, Mcp-Session-Id');
1137
+ res.header('Access-Control-Expose-Headers', 'Mcp-Session-Id');
1138
+
1139
+ if (req.method === 'OPTIONS') {
1140
+ return res.sendStatus(200);
1141
+ }
1142
+ next();
1143
+ });
1144
+
1145
+ // Streamable HTTP endpoint (stateless mode - recommended)
1146
+ app.post('/mcp', async (req, res) => {
1147
+ try {
1148
+ // Create a new transport for each request to prevent request ID collisions
1149
+ const transport = new StreamableHTTPServerTransport({
1150
+ sessionIdGenerator: undefined,
1151
+ enableJsonResponse: true
1152
+ });
1153
+
1154
+ res.on('close', () => {
1155
+ transport.close();
1156
+ });
1152
1157
 
1153
- res.writeHead(200, {
1154
- 'Content-Type': 'application/json',
1155
- 'Access-Control-Allow-Origin': '*',
1156
- 'Access-Control-Allow-Methods': 'GET',
1157
- 'Access-Control-Allow-Headers': 'Content-Type'
1158
+ await this.server.connect(transport);
1159
+ await transport.handleRequest(req, res, req.body);
1160
+ } catch (error) {
1161
+ console.error('Error handling MCP request:', error);
1162
+ if (!res.headersSent) {
1163
+ res.status(500).json({
1164
+ jsonrpc: '2.0',
1165
+ error: {
1166
+ code: -32603,
1167
+ message: 'Internal server error'
1168
+ },
1169
+ id: null
1158
1170
  });
1159
- res.end(JSON.stringify(healthStatus, null, 2));
1160
- } else {
1161
- res.writeHead(404, { 'Content-Type': 'text/plain' });
1162
- res.end('Not Found');
1163
1171
  }
1164
- });
1172
+ }
1173
+ });
1165
1174
 
1166
- // Listen on a different port for health checks to avoid conflicts
1167
- const healthPort = parseInt(port) + 1;
1168
- healthServer.listen(healthPort, host, () => {
1169
- console.log(`Health check endpoint: http://${host}:${healthPort}/health`);
1170
- });
1171
- }).catch(err => {
1172
- console.warn('Could not setup health check endpoint:', err.message);
1175
+ // Health check endpoint
1176
+ app.get('/health', (req, res) => {
1177
+ const healthStatus = {
1178
+ status: 'healthy',
1179
+ timestamp: new Date().toISOString(),
1180
+ uptime: process.uptime(),
1181
+ version: SERVER_VERSION,
1182
+ database: {
1183
+ connected: database ? database.isConnected() : false,
1184
+ bucket: database?.bucket || null,
1185
+ keyPrefix: database?.keyPrefix || null,
1186
+ resourceCount: database ? Object.keys(database.resources || {}).length : 0
1187
+ },
1188
+ memory: process.memoryUsage(),
1189
+ environment: {
1190
+ nodeVersion: process.version,
1191
+ platform: process.platform,
1192
+ transport: 'streamable-http'
1193
+ }
1194
+ };
1195
+
1196
+ res.json(healthStatus);
1197
+ });
1198
+
1199
+ // Start Express server
1200
+ app.listen(port, host, () => {
1201
+ console.log(`S3DB MCP Server running on http://${host}:${port}/mcp`);
1202
+ console.log(`Health check endpoint: http://${host}:${port}/health`);
1203
+ }).on('error', error => {
1204
+ console.error('Server error:', error);
1205
+ process.exit(1);
1173
1206
  });
1174
1207
  }
1175
1208
 
1209
+
1176
1210
  // 📖 DOCUMENTATION TOOLS HANDLERS
1177
1211
 
1178
1212
  async handleS3dbQueryDocs(args) {
@@ -2718,8 +2752,8 @@ async function main() {
2718
2752
 
2719
2753
  console.log(`S3DB MCP Server v${SERVER_VERSION} started`);
2720
2754
  console.log(`Transport: ${args.transport}`);
2721
- if (args.transport === 'sse') {
2722
- console.log(`URL: http://${args.host}:${args.port}/sse`);
2755
+ if (args.transport === 'http') {
2756
+ console.log(`URL: http://${args.host}:${args.port}/mcp`);
2723
2757
  }
2724
2758
  }
2725
2759
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "s3db.js",
3
- "version": "12.0.0",
3
+ "version": "12.1.0",
4
4
  "description": "Use AWS S3, the world's most reliable document storage, as a database with this ORM.",
5
5
  "main": "dist/s3db.cjs.js",
6
6
  "module": "dist/s3db.es.js",
@@ -65,11 +65,12 @@
65
65
  "UNLICENSE"
66
66
  ],
67
67
  "dependencies": {
68
- "@aws-sdk/client-s3": "^3.906.0",
69
- "@modelcontextprotocol/sdk": "^1.19.1",
70
- "@smithy/node-http-handler": "^4.3.0",
68
+ "@aws-sdk/client-s3": "^3.914.0",
69
+ "@modelcontextprotocol/sdk": "^1.20.1",
70
+ "@smithy/node-http-handler": "^4.4.2",
71
71
  "@supercharge/promise-pool": "^3.2.0",
72
72
  "dotenv": "^17.2.3",
73
+ "express": "^5.1.0",
73
74
  "fastest-validator": "^1.19.1",
74
75
  "flat": "^6.0.1",
75
76
  "glob": "^11.0.3",
@@ -114,36 +115,35 @@
114
115
  }
115
116
  },
116
117
  "devDependencies": {
117
- "@aws-sdk/client-sqs": "^3.0.0",
118
+ "@aws-sdk/client-sqs": "^3.914.0",
118
119
  "@babel/core": "^7.28.4",
119
120
  "@babel/preset-env": "^7.28.3",
120
- "@google-cloud/bigquery": "^7.0.0",
121
- "@rollup/plugin-commonjs": "^28.0.6",
121
+ "@google-cloud/bigquery": "^7.9.4",
122
+ "@rollup/plugin-commonjs": "^28.0.8",
122
123
  "@rollup/plugin-json": "^6.1.0",
123
- "@rollup/plugin-node-resolve": "^16.0.2",
124
+ "@rollup/plugin-node-resolve": "^16.0.3",
124
125
  "@rollup/plugin-replace": "^6.0.2",
125
126
  "@rollup/plugin-terser": "^0.4.4",
126
127
  "@types/node": "24.7.0",
127
128
  "@xenova/transformers": "^2.17.2",
128
- "amqplib": "^0.10.8",
129
+ "amqplib": "^0.10.9",
129
130
  "babel-loader": "^10.0.0",
130
131
  "chalk": "^5.6.2",
131
132
  "cli-table3": "^0.6.5",
132
133
  "commander": "^14.0.1",
133
- "esbuild": "^0.25.10",
134
- "inquirer": "^12.9.6",
134
+ "esbuild": "^0.25.11",
135
+ "inquirer": "^12.10.0",
135
136
  "jest": "^30.2.0",
136
- "node-cron": "^4.0.0",
137
+ "node-cron": "^4.2.1",
137
138
  "node-loader": "^2.1.0",
138
139
  "ora": "^9.0.0",
139
- "pg": "^8.0.0",
140
+ "pg": "^8.16.3",
140
141
  "pkg": "^5.8.1",
141
- "rollup": "^4.52.4",
142
+ "rollup": "^4.52.5",
142
143
  "rollup-plugin-copy": "^3.5.0",
143
144
  "rollup-plugin-esbuild": "^6.2.1",
144
145
  "rollup-plugin-polyfill-node": "^0.13.0",
145
146
  "rollup-plugin-shebang-bin": "^0.1.0",
146
- "rollup-plugin-terser": "^7.0.2",
147
147
  "tsx": "^4.20.6",
148
148
  "typescript": "5.9.3",
149
149
  "uuid": "^13.0.0",
@@ -203,7 +203,21 @@ export class Client extends EventEmitter {
203
203
  Key: keyPrefix ? path.join(keyPrefix, key) : key,
204
204
  };
205
205
 
206
- const [ok, err, response] = await tryFn(() => this.sendCommand(new HeadObjectCommand(options)));
206
+ const [ok, err, response] = await tryFn(async () => {
207
+ const res = await this.sendCommand(new HeadObjectCommand(options));
208
+
209
+ // Smart decode metadata values (same as getObject)
210
+ if (res.Metadata) {
211
+ const decodedMetadata = {};
212
+ for (const [key, value] of Object.entries(res.Metadata)) {
213
+ decodedMetadata[key] = metadataDecode(value);
214
+ }
215
+ res.Metadata = decodedMetadata;
216
+ }
217
+
218
+ return res;
219
+ });
220
+
207
221
  this.emit('headObject', err || response, { key });
208
222
 
209
223
  if (!ok) {
@@ -218,15 +232,36 @@ export class Client extends EventEmitter {
218
232
  return response;
219
233
  }
220
234
 
221
- async copyObject({ from, to }) {
235
+ async copyObject({ from, to, metadata, metadataDirective, contentType }) {
236
+ const keyPrefix = typeof this.config.keyPrefix === 'string' ? this.config.keyPrefix : '';
222
237
  const options = {
223
238
  Bucket: this.config.bucket,
224
- Key: this.config.keyPrefix ? path.join(this.config.keyPrefix, to) : to,
225
- CopySource: path.join(this.config.bucket, this.config.keyPrefix ? path.join(this.config.keyPrefix, from) : from),
239
+ Key: keyPrefix ? path.join(keyPrefix, to) : to,
240
+ CopySource: path.join(this.config.bucket, keyPrefix ? path.join(keyPrefix, from) : from),
226
241
  };
227
242
 
243
+ // Add metadata directive if specified
244
+ if (metadataDirective) {
245
+ options.MetadataDirective = metadataDirective; // 'COPY' or 'REPLACE'
246
+ }
247
+
248
+ // Add metadata if specified (and encode values)
249
+ if (metadata && typeof metadata === 'object') {
250
+ const encodedMetadata = {};
251
+ for (const [key, value] of Object.entries(metadata)) {
252
+ const { encoded } = metadataEncode(value);
253
+ encodedMetadata[key] = encoded;
254
+ }
255
+ options.Metadata = encodedMetadata;
256
+ }
257
+
258
+ // Add content type if specified
259
+ if (contentType) {
260
+ options.ContentType = contentType;
261
+ }
262
+
228
263
  const [ok, err, response] = await tryFn(() => this.sendCommand(new CopyObjectCommand(options)));
229
- this.emit('copyObject', err || response, { from, to });
264
+ this.emit('copyObject', err || response, { from, to, metadataDirective });
230
265
 
231
266
  if (!ok) {
232
267
  throw mapAwsError(err, {
@@ -456,7 +456,9 @@ export class PluginStorage {
456
456
  * @returns {Promise<boolean>} True if extended, false if not found or no TTL
457
457
  */
458
458
  async touch(key, additionalSeconds) {
459
- const [ok, err, response] = await tryFn(() => this.client.getObject(key));
459
+ // Optimization: Use HEAD + COPY instead of GET + PUT for metadata-only updates
460
+ // This avoids transferring the body when only updating the TTL
461
+ const [ok, err, response] = await tryFn(() => this.client.headObject(key));
460
462
 
461
463
  if (!ok) {
462
464
  return false;
@@ -465,50 +467,34 @@ export class PluginStorage {
465
467
  const metadata = response.Metadata || {};
466
468
  const parsedMetadata = this._parseMetadataValues(metadata);
467
469
 
468
- let data = parsedMetadata;
469
-
470
- if (response.Body) {
471
- const [ok, err, result] = await tryFn(async () => {
472
- const bodyContent = await response.Body.transformToString();
473
- if (bodyContent && bodyContent.trim()) {
474
- const body = JSON.parse(bodyContent);
475
- return { ...parsedMetadata, ...body };
476
- }
477
- return parsedMetadata;
478
- });
479
-
480
- if (!ok) {
481
- return false; // Parse error
482
- }
483
-
484
- data = result;
485
- }
486
-
487
470
  // S3 lowercases metadata keys
488
- const expiresAt = data._expiresat || data._expiresAt;
471
+ const expiresAt = parsedMetadata._expiresat || parsedMetadata._expiresAt;
489
472
  if (!expiresAt) {
490
473
  return false; // No TTL to extend
491
474
  }
492
475
 
493
476
  // Extend TTL - use the standard field name (will be lowercased by S3)
494
- data._expiresAt = expiresAt + (additionalSeconds * 1000);
495
- delete data._expiresat; // Remove lowercased version
496
-
497
- // Save back (reuse same behavior)
498
- const { metadata: newMetadata, body: newBody } = this._applyBehavior(data, 'body-overflow');
499
-
500
- const putParams = {
501
- key,
502
- metadata: newMetadata,
503
- contentType: 'application/json'
504
- };
505
-
506
- if (newBody !== null) {
507
- putParams.body = JSON.stringify(newBody);
477
+ parsedMetadata._expiresAt = expiresAt + (additionalSeconds * 1000);
478
+ delete parsedMetadata._expiresat; // Remove lowercased version
479
+
480
+ // Encode metadata for S3
481
+ const encodedMetadata = {};
482
+ for (const [metaKey, metaValue] of Object.entries(parsedMetadata)) {
483
+ const { encoded } = metadataEncode(metaValue);
484
+ encodedMetadata[metaKey] = encoded;
508
485
  }
509
486
 
510
- const [putOk] = await tryFn(() => this.client.putObject(putParams));
511
- return putOk;
487
+ // Use COPY with MetadataDirective: REPLACE to update metadata atomically
488
+ // This preserves the body without re-transferring it
489
+ const [copyOk] = await tryFn(() => this.client.copyObject({
490
+ from: key,
491
+ to: key,
492
+ metadata: encodedMetadata,
493
+ metadataDirective: 'REPLACE',
494
+ contentType: response.ContentType || 'application/json'
495
+ }));
496
+
497
+ return copyOk;
512
498
  }
513
499
 
514
500
  /**
@@ -675,12 +661,56 @@ export class PluginStorage {
675
661
  /**
676
662
  * Increment a counter value
677
663
  *
664
+ * Optimization: Uses HEAD + COPY for existing counters to avoid body transfer.
665
+ * Falls back to GET + PUT for non-existent counters or those with additional data.
666
+ *
678
667
  * @param {string} key - S3 key
679
668
  * @param {number} amount - Amount to increment (default: 1)
680
669
  * @param {Object} options - Options (e.g., ttl)
681
670
  * @returns {Promise<number>} New value
682
671
  */
683
672
  async increment(key, amount = 1, options = {}) {
673
+ // Try optimized path first: HEAD + COPY for existing counters
674
+ const [headOk, headErr, headResponse] = await tryFn(() => this.client.headObject(key));
675
+
676
+ if (headOk && headResponse.Metadata) {
677
+ // Counter exists, use optimized HEAD + COPY
678
+ const metadata = headResponse.Metadata || {};
679
+ const parsedMetadata = this._parseMetadataValues(metadata);
680
+
681
+ const currentValue = parsedMetadata.value || 0;
682
+ const newValue = currentValue + amount;
683
+
684
+ // Update only the value field
685
+ parsedMetadata.value = newValue;
686
+
687
+ // Handle TTL if specified
688
+ if (options.ttl) {
689
+ parsedMetadata._expiresAt = Date.now() + (options.ttl * 1000);
690
+ }
691
+
692
+ // Encode metadata
693
+ const encodedMetadata = {};
694
+ for (const [metaKey, metaValue] of Object.entries(parsedMetadata)) {
695
+ const { encoded } = metadataEncode(metaValue);
696
+ encodedMetadata[metaKey] = encoded;
697
+ }
698
+
699
+ // Atomic update via COPY
700
+ const [copyOk] = await tryFn(() => this.client.copyObject({
701
+ from: key,
702
+ to: key,
703
+ metadata: encodedMetadata,
704
+ metadataDirective: 'REPLACE',
705
+ contentType: headResponse.ContentType || 'application/json'
706
+ }));
707
+
708
+ if (copyOk) {
709
+ return newValue;
710
+ }
711
+ }
712
+
713
+ // Fallback: counter doesn't exist or has body data, use traditional path
684
714
  const data = await this.get(key);
685
715
  const value = (data?.value || 0) + amount;
686
716
  await this.set(key, { value }, options);
@@ -37,6 +37,11 @@ import { ApiServer } from './server.js';
37
37
  import { requirePluginDependency } from '../concerns/plugin-dependencies.js';
38
38
  import tryFn from '../../concerns/try-fn.js';
39
39
 
40
+ /**
41
+ * API Plugin class
42
+ * @class
43
+ * @extends Plugin
44
+ */
40
45
  export class ApiPlugin extends Plugin {
41
46
  /**
42
47
  * Create API Plugin instance
@@ -12,6 +12,10 @@ import { errorHandler } from './utils/error-handler.js';
12
12
  import * as formatter from './utils/response-formatter.js';
13
13
  import { generateOpenAPISpec } from './utils/openapi-generator.js';
14
14
 
15
+ /**
16
+ * API Server class
17
+ * @class
18
+ */
15
19
  export class ApiServer {
16
20
  /**
17
21
  * Create API server
@@ -176,6 +176,11 @@ export class Plugin extends EventEmitter {
176
176
  * - Pode modificar argumentos/resultados.
177
177
  */
178
178
  addMiddleware(resource, methodName, middleware) {
179
+ // Safety check: verify method exists
180
+ if (typeof resource[methodName] !== 'function') {
181
+ throw new Error(`Cannot add middleware to "${methodName}": method does not exist on resource "${resource.name || 'unknown'}"`);
182
+ }
183
+
179
184
  if (!resource._pluginMiddlewares) {
180
185
  resource._pluginMiddlewares = {};
181
186
  }