Skip to content

Commit 9cacd5e

Browse files
authored
feat(plugins): add BucketResolverPlugin for multi-bucket dataset queries (#51)
* feat(plugins): add BucketResolverPlugin for multi-bucket dataset queries Resolves a missing bucket/endpoint per file by matching parsed file paths against configured dataset prefixes. Runs after QueryParserPlugin so each file in a query is resolved independently, letting a single query join across datasets that live in different buckets. Opt-in via the plugins array, consistent with FSPurgePlugin/StatsPlugin/AvroPlugin. * fix(s3): avoid day-of-month collision in evictTodayFromListingCache test The "past day" cache fixture was hardcoded to day=01, colliding with today's own day-level prefix whenever the suite runs on the 1st of the month (UTC) and making the "past day entries should remain" assertion fail. Derive it relative to the actual current day instead. * feat(plugins): resolve list_files bucket via BucketResolverPlugin list_files talks to S3 directly and never runs through the query pipeline that BucketResolverPlugin's processQuery hooks into, so it couldn't auto-resolve a bucket from datasets. Add a resolveListFiles hook to the plugin (sharing its prefix-matching logic with processQuery) and have ListFilesTool reduce over config.plugins to call it — kept local to the MCP tool rather than lifecycle.js, since that module is scoped to the s3quoia() query/download pipeline which list_files never touches. Adding BucketResolverPlugin to config.plugins now covers both the query and list_files tools with no extra configuration.
1 parent e67dad5 commit 9cacd5e

9 files changed

Lines changed: 390 additions & 12 deletions

File tree

‎README.md‎

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,27 @@ const results = await s3quoia({
233233
});
234234
```
235235

236+
### BucketResolverPlugin
237+
238+
When a query references files by relative path (no `{bucket:...}` token) and no `defaultBucket` is set, `BucketResolverPlugin` resolves each file's bucket and endpoint from a list of dataset configs (see [Dataset options](#dataset-options)), matching the file path against each dataset's `prefix`. Resolution runs per file, so a single query can join across datasets in different buckets. Files that don't match any prefix are left unresolved — the existing "no bucket configured" error still applies to them.
239+
240+
```js
241+
import s3quoia, { BucketResolverPlugin } from 's3quoia';
242+
243+
const datasets = [
244+
{ name: 'sales', prefix: 'sales/', bucket: 'sales-bucket' },
245+
{ name: 'logs', prefix: 'logs/raw/', bucket: 'logs-bucket', endpoint: 'https://s3.logs.example.com' },
246+
];
247+
248+
const results = await s3quoia({
249+
// ...
250+
plugins: [new BucketResolverPlugin(datasets)],
251+
query: `SELECT * FROM read_parquet('sales/year={yyyy}/data.parquet')`,
252+
});
253+
```
254+
255+
When used with `S3QuoiaMCP` (see [Plugins](#plugins-1)), the same `BucketResolverPlugin` instance also resolves the bucket for the `list_files` tool — it calls a second hook, `resolveListFiles`, since `list_files` talks to S3 directly and doesn't go through the query pipeline that `processQuery` hooks into.
256+
236257
## MCP Server
237258

238259
s3quoia ships a [Model Context Protocol](https://modelcontextprotocol.io/) server that exposes three tools to any MCP-compatible client (Claude Desktop, Claude Code, IBM Bob etc.):
@@ -405,22 +426,25 @@ new S3QuoiaMCP({
405426

406427
#### Plugins
407428

408-
Pass a `plugins` array to enable `FSPurgePlugin`, `StatsPlugin`, or any custom plugin for every query the server handles:
429+
Pass a `plugins` array to enable `FSPurgePlugin`, `StatsPlugin`, `BucketResolverPlugin`, or any custom plugin for every query the server handles:
409430

410431
```js
411432
import { S3QuoiaMCP } from 's3quoia/mcp';
412-
import { FSPurgePlugin, StatsPlugin } from 's3quoia';
433+
import { FSPurgePlugin, StatsPlugin, BucketResolverPlugin } from 's3quoia';
434+
435+
const datasets = [ /* ... */ ];
413436

414437
new S3QuoiaMCP({
415-
datasets: [ /* ... */ ],
438+
datasets,
416439
plugins: [
417440
new FSPurgePlugin({ bucketsDir: '/tmp/s3quoia', lastAccessTTLMinutes: 120 }),
418441
new StatsPlugin((event) => console.error(event)),
442+
new BucketResolverPlugin(datasets),
419443
],
420444
}).start();
421445
```
422446

423-
The built-in server (`npx s3quoia`) runs `FSPurgePlugin` and `StatsPlugin` by default. When extending with `S3QuoiaMCP`, plugins are opt-in.
447+
The built-in server (`npx s3quoia`) runs `FSPurgePlugin` and `StatsPlugin` by default. When extending with `S3QuoiaMCP`, plugins are opt-in — add `BucketResolverPlugin` explicitly if you want both `query` and `list_files` to auto-resolve a bucket per file from your `datasets` config. `list_files` doesn't run through the query pipeline, so the plugin resolves it via a separate `resolveListFiles` hook rather than `processQuery` — no extra config needed, adding the plugin once covers both tools.
424448

425449
### Adding custom tools
426450

‎e2e/list-files.e2e.js‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { describe, it } from 'node:test';
2+
import assert from 'node:assert';
3+
4+
// ListFilesTool reads S3 credentials/endpoint from process.env at module load
5+
// time, so these must be set before it's imported. S3_BUCKET is deliberately
6+
// left unset — bucket resolution for these tests comes entirely from
7+
// BucketResolverPlugin via the datasets config.
8+
process.env.S3_ACCESS_KEY_ID = 'test-access-key';
9+
process.env.S3_SECRET_ACCESS_KEY = 'test-secret-key';
10+
process.env.S3_ENDPOINT = 'http://localhost:9000';
11+
delete process.env.S3_BUCKET;
12+
13+
const { default: ListFilesTool } = await import('../src/mcp/tools/list-files/list-files.js');
14+
const { BucketResolverPlugin } = await import('../src/s3quoia.js');
15+
16+
const BUCKET = 'test-bucket';
17+
const BUCKET_2 = 'test-bucket-2';
18+
19+
describe('list_files e2e', () => {
20+
it('resolves the bucket via BucketResolverPlugin when no bucket param or S3_BUCKET is set', async () => {
21+
const datasets = [{ name: 'reports', prefix: 'reports/', bucket: BUCKET }];
22+
const tool = new ListFilesTool({ plugins: [new BucketResolverPlugin(datasets)] });
23+
24+
const result = await tool.handler({ prefix: 'reports/' });
25+
const parsed = JSON.parse(result.content[0].text);
26+
27+
assert.ok(parsed.files.some((file) => file.file === 'reports/summary.parquet'));
28+
assert.ok(parsed.files.some((file) => file.file === 'reports/summary.csv'));
29+
});
30+
31+
it('resolves different prefixes to different buckets in the same server process', async () => {
32+
const datasets = [
33+
{ name: 'reports', prefix: 'reports/', bucket: BUCKET },
34+
{ name: 'reference', prefix: 'reference', bucket: BUCKET_2 },
35+
];
36+
const tool = new ListFilesTool({ plugins: [new BucketResolverPlugin(datasets)] });
37+
38+
const reportsResult = await tool.handler({ prefix: 'reports/' });
39+
const referenceResult = await tool.handler({ prefix: 'reference' });
40+
41+
const reportsParsed = JSON.parse(reportsResult.content[0].text);
42+
const referenceParsed = JSON.parse(referenceResult.content[0].text);
43+
44+
assert.ok(reportsParsed.files.some((file) => file.file === 'reports/summary.parquet'));
45+
assert.ok(referenceParsed.files.some((file) => file.file === 'reference.parquet'));
46+
});
47+
48+
it('rejects when no dataset prefix matches and no bucket/S3_BUCKET is set — no silent fallback', async () => {
49+
const datasets = [{ name: 'reports', prefix: 'reports/', bucket: BUCKET }];
50+
const tool = new ListFilesTool({ plugins: [new BucketResolverPlugin(datasets)] });
51+
52+
await assert.rejects(() => tool.handler({ prefix: 'unrelated/' }));
53+
});
54+
55+
it('prefers an explicit bucket param over plugin resolution', async () => {
56+
const datasets = [{ name: 'reports', prefix: 'reports/', bucket: 'wrong-bucket' }];
57+
const tool = new ListFilesTool({ plugins: [new BucketResolverPlugin(datasets)] });
58+
59+
const result = await tool.handler({ prefix: 'reference.parquet', bucket: BUCKET_2 });
60+
const parsed = JSON.parse(result.content[0].text);
61+
62+
assert.ok(parsed.files.some((file) => file.file === 'reference.parquet'));
63+
});
64+
});

‎e2e/s3quoia.e2e.js‎

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
44
import { tmpdir } from 'node:os';
55
import { join } from 'node:path';
66

7-
import s3quoia, { StatsPlugin, AvroPlugin } from '../src/s3quoia.js';
7+
import s3quoia, { StatsPlugin, AvroPlugin, BucketResolverPlugin } from '../src/s3quoia.js';
88

99
const ENDPOINT = 'http://localhost:9000';
1010
const BUCKET = 'test-bucket';
@@ -433,4 +433,63 @@ describe('s3quoia e2e', () => {
433433
assert.strictEqual(result[0].description, 'user login event');
434434
assert.strictEqual(result[2].description, 'purchase completed');
435435
});
436+
437+
it('resolves a bucket from datasets via BucketResolverPlugin when no defaultBucket is set', async () => {
438+
const datasetDir = await mkdtemp(join(tmpdir(), 's3-e2e-bucket-resolver-'));
439+
const datasets = [{ name: 'reports', prefix: 'reports/', bucket: BUCKET }];
440+
441+
try {
442+
const result = await s3quoia({
443+
accessKeyId: ACCESS_KEY,
444+
secretAccessKey: SECRET_KEY,
445+
defaultEndpoint: ENDPOINT,
446+
bucketsDir: datasetDir,
447+
plugins: [new BucketResolverPlugin(datasets)],
448+
from: FROM,
449+
to: TO,
450+
query: `SELECT * FROM read_parquet('reports/summary.parquet') ORDER BY id`,
451+
format: 'jsonRecords',
452+
});
453+
454+
assert.strictEqual(result.length, 3);
455+
assert.deepStrictEqual(result[0], { id: 1, event_type: 'login', region: 'us-east', value: 42.5 });
456+
} finally {
457+
await rm(datasetDir, { recursive: true });
458+
}
459+
});
460+
461+
it('joins files from two different buckets resolved independently via BucketResolverPlugin, with no bucket tokens in the query', async () => {
462+
const datasetDir = await mkdtemp(join(tmpdir(), 's3-e2e-bucket-resolver-join-'));
463+
const datasets = [
464+
{ name: 'reports', prefix: 'reports/', bucket: BUCKET },
465+
{ name: 'reference', prefix: 'reference', bucket: BUCKET_2 },
466+
];
467+
468+
try {
469+
const result = await s3quoia({
470+
accessKeyId: ACCESS_KEY,
471+
secretAccessKey: SECRET_KEY,
472+
defaultEndpoint: ENDPOINT,
473+
bucketsDir: datasetDir,
474+
plugins: [new BucketResolverPlugin(datasets)],
475+
from: FROM,
476+
to: TO,
477+
query: `
478+
SELECT s.id, s.event_type, r.description
479+
FROM read_parquet('reports/summary.parquet') s
480+
JOIN read_parquet('reference.parquet') r ON s.id = r.id
481+
ORDER BY s.id
482+
`,
483+
format: 'jsonRecords',
484+
});
485+
486+
assert.strictEqual(result.length, 3);
487+
assert.strictEqual(result[0].id, 1);
488+
assert.strictEqual(result[0].event_type, 'login');
489+
assert.strictEqual(result[0].description, 'user login event');
490+
assert.strictEqual(result[2].description, 'purchase completed');
491+
} finally {
492+
await rm(datasetDir, { recursive: true });
493+
}
494+
});
436495
});

‎src/mcp/tools/list-files/list-files.js‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,13 @@ export default class ListFilesTool extends BaseTool {
4141
}
4242

4343
async handler({ prefix = '', maxResults = 100, endpoint, bucket }) {
44-
const resolvedEndpoint = endpoint || S3_ENDPOINT;
45-
const resolvedBucket = bucket || S3_BUCKET;
44+
const resolved = resolveBucket(this.config.plugins, {
45+
prefix,
46+
bucket: bucket || S3_BUCKET,
47+
endpoint: endpoint || S3_ENDPOINT,
48+
});
49+
const resolvedEndpoint = resolved.endpoint;
50+
const resolvedBucket = resolved.bucket;
4651
const s3Client = buildS3Client({
4752
apiKey: S3_API_KEY,
4853
accessKeyId: S3_ACCESS_KEY_ID,
@@ -70,6 +75,10 @@ export default class ListFilesTool extends BaseTool {
7075

7176
/** Helpers */
7277

78+
function resolveBucket(plugins = [], context) {
79+
return plugins.reduce((result, plugin) => plugin.resolveListFiles?.(result) ?? result, context);
80+
}
81+
7382
function getRepresentativeFiles(files) {
7483
const parquetFiles = files.filter(({ file }) => file.endsWith('.parquet'));
7584
const dirMap = parquetFiles.reduce(addFirstFilePerDir, new Map());

‎src/mcp/tools/list-files/list-files.test.js‎

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,51 @@
11
import { describe, it } from 'node:test';
22
import assert from 'node:assert';
33
import esmock from 'esmock';
4+
import BucketResolverPlugin from '../../../plugins/bucket-resolver/bucket-resolver-plugin.js';
45

56
describe('handleListFiles', () => {
7+
it('resolves the bucket from a BucketResolverPlugin in config.plugins when no bucket/S3_BUCKET is set', async () => {
8+
const sentParams = [];
9+
const datasets = [{ name: 'sales', prefix: 'sales/', bucket: 'sales-bucket' }];
10+
const tool = await getMockedTool({
11+
s3Response: { Contents: [], CommonPrefixes: [], IsTruncated: false },
12+
config: { plugins: [new BucketResolverPlugin(datasets)] },
13+
sentParams,
14+
});
15+
16+
await tool.handler({ prefix: 'sales/year=2024/' });
17+
18+
assert.strictEqual(sentParams[0].Bucket, 'sales-bucket');
19+
});
20+
21+
it('prefers an explicit bucket param over plugin resolution', async () => {
22+
const sentParams = [];
23+
const datasets = [{ name: 'sales', prefix: 'sales/', bucket: 'plugin-bucket' }];
24+
const tool = await getMockedTool({
25+
s3Response: { Contents: [], CommonPrefixes: [], IsTruncated: false },
26+
config: { plugins: [new BucketResolverPlugin(datasets)] },
27+
sentParams,
28+
});
29+
30+
await tool.handler({ prefix: 'sales/', bucket: 'explicit-bucket' });
31+
32+
assert.strictEqual(sentParams[0].Bucket, 'explicit-bucket');
33+
});
34+
35+
it('leaves the bucket undefined when no plugin matches the prefix, with no fallback', async () => {
36+
const sentParams = [];
37+
const datasets = [{ name: 'sales', prefix: 'sales/', bucket: 'sales-bucket' }];
38+
const tool = await getMockedTool({
39+
s3Response: { Contents: [], CommonPrefixes: [], IsTruncated: false },
40+
config: { plugins: [new BucketResolverPlugin(datasets)] },
41+
sentParams,
42+
});
43+
44+
await tool.handler({ prefix: 'unrelated/' });
45+
46+
assert.strictEqual(sentParams[0].Bucket, undefined);
47+
});
48+
649
it('returns directories, files, and truncated flag from S3', async () => {
750
const tool = await getMockedTool({
851
s3Response: {
@@ -85,8 +128,13 @@ describe('handleListFiles', () => {
85128
});
86129
});
87130

88-
async function getMockedTool({ s3Response, columns = [] }) {
89-
const mockS3Client = { send: () => Promise.resolve(s3Response) };
131+
async function getMockedTool({ s3Response, columns = [], config = {}, sentParams = [] }) {
132+
const mockS3Client = {
133+
send: (command) => {
134+
sentParams.push(command);
135+
return Promise.resolve(s3Response);
136+
},
137+
};
90138
const { default: ListFilesTool } = await esmock('./list-files.js', {
91139
'@aws-sdk/client-s3': {
92140
ListObjectsV2Command: class ListObjectsV2Command {
@@ -99,5 +147,5 @@ async function getMockedTool({ s3Response, columns = [] }) {
99147
'../../../utils/parquet-schema-reader.js': { readParquetColumns: () => Promise.resolve(columns) },
100148
'../../../utils/bigint-replacer.js': { bigintReplacer: (_, val) => val },
101149
});
102-
return new ListFilesTool({});
150+
return new ListFilesTool(config);
103151
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/**
2+
* Fills in a missing `bucket`/`endpoint` by matching a file path against
3+
* configured dataset prefixes. Exposes two hooks over the same matching
4+
* logic:
5+
*
6+
* - `processQuery` — runs after `QueryParserPlugin`, so `context.settings`
7+
* already holds one parsed entry per file reference (e.g.
8+
* `sales/year={yyyy}/data.parquet`). Matching is done per file rather than
9+
* once for the whole query, so a join across two datasets resolves each
10+
* file to its own bucket independently.
11+
* - `resolveListFiles` — called directly by the `list_files` MCP tool, which
12+
* talks to S3 without going through the query pipeline, so it resolves its
13+
* own `{ prefix, bucket, endpoint }` context the same way.
14+
*
15+
* A path with no matching dataset prefix is left unchanged in both cases —
16+
* no fallback to the first configured dataset — so the existing "no bucket"
17+
* error surfaces normally instead of silently querying the wrong bucket.
18+
*/
19+
export default class BucketResolverPlugin {
20+
name = 'BucketResolverPlugin';
21+
22+
constructor(datasets = []) {
23+
this.datasets = datasets;
24+
}
25+
26+
processQuery(context) {
27+
const settings = context.settings.map((setting) => this.resolveSetting(setting));
28+
return { ...context, settings };
29+
}
30+
31+
resolveListFiles(context) {
32+
return this.mergeDataset(context, context.prefix);
33+
}
34+
35+
resolveSetting(setting) {
36+
return this.mergeDataset(setting, setting.file);
37+
}
38+
39+
mergeDataset(context, path) {
40+
if (context.bucket) return context;
41+
const dataset = this.findDataset(path);
42+
if (!dataset) return context;
43+
return { ...context, bucket: dataset.bucket, endpoint: context.endpoint ?? dataset.endpoint };
44+
}
45+
46+
findDataset(path) {
47+
const candidates = this.datasets.filter(({ prefix }) => prefix && path?.startsWith(prefix));
48+
if (!candidates.length) return undefined;
49+
return candidates.reduce((best, dataset) => (dataset.prefix.length > best.prefix.length ? dataset : best));
50+
}
51+
}

0 commit comments

Comments
 (0)