Skip to content

Commit e3d72a7

Browse files
author
Dev Agent Amelia
committed
refactor: extract batchExecute to DataverseBatchClient to respect 400-line limit
- Move batchExecute from DataverseClient to new DataverseBatchClient (dataverse-client.batch.ts) - Expose HttpClient.baseURL as readonly (no secrets, needed by batch builder) - Chain: DataverseClient -> DataverseMetadataClient -> DataverseBatchClient -> DataverseAdvancedClient - Update batch.tools.ts + test files to use DataverseBatchClient - All 423 tests passing
1 parent 5eea688 commit e3d72a7

9 files changed

Lines changed: 99 additions & 86 deletions

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,7 @@ project.md
1616
# Editor personal settings
1717
.vscode/settings.json
1818
.vscode/launch.json
19+
20+
# MCP Registry auth tokens (written by mcp-publisher CLI)
21+
.mcpregistry_github_token
22+
.mcpregistry_registry_token

server.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
33
"name": "io.github.codeurali/dataverse",
44
"title": "MCP Dataverse",
5-
"description": "MCP server exposing 48 Microsoft Dataverse Web API tools for AI agents — query, CRUD, metadata, search, files, audit, and more.",
5+
"description": "48 tools for Microsoft Dataverse: query, CRUD, metadata, search, files, audit, batch, and more.",
66
"repository": {
77
"url": "https://github.com/codeurali/mcp-dataverse",
88
"source": "github"

src/dataverse/dataverse-client-advanced.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { DataverseMetadataClient } from './dataverse-client.metadata.js';
1+
import { DataverseBatchClient } from './dataverse-client.batch.js';
22
import type { ODataResponse } from './types.js';
33
import { esc } from './dataverse-client.utils.js';
44

@@ -22,7 +22,7 @@ function xmlEscape(v: string): string {
2222
* Extends DataverseMetadataClient with advanced query capabilities:
2323
* bound functions, server-side paging, and change tracking (delta queries).
2424
*/
25-
export class DataverseAdvancedClient extends DataverseMetadataClient {
25+
export class DataverseAdvancedClient extends DataverseBatchClient {
2626

2727
// ─── Advanced Actions & Functions ────────────────────────────────────────
2828

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { DataverseMetadataClient } from './dataverse-client.metadata.js';
2+
import type { BatchRequest } from './types.js';
3+
import { parseMultipartResponse } from './dataverse-client.utils.js';
4+
5+
/**
6+
* Extends DataverseMetadataClient with batch execution support.
7+
* Kept in a separate file to stay within the 400-line hard limit.
8+
*/
9+
export class DataverseBatchClient extends DataverseMetadataClient {
10+
// ─── Batch ───────────────────────────────────────────────────────────────
11+
12+
async batchExecute(requests: BatchRequest[], useChangeset = false): Promise<unknown[]> {
13+
const batchId = `batch_${Date.now()}`;
14+
let body = '';
15+
16+
if (useChangeset) {
17+
const changesetId = `changeset_${Date.now() + 1}`;
18+
const getOps = requests.filter(r => r.method === 'GET');
19+
const mutatingOps = requests.filter(r => r.method !== 'GET');
20+
21+
for (const req of getOps) {
22+
body += `--${batchId}\n`;
23+
body += `Content-Type: application/http\n`;
24+
body += `Content-Transfer-Encoding: binary\n\n`;
25+
body += `${req.method} ${this.http.baseURL}${req.url} HTTP/1.1\n`;
26+
body += `Accept: application/json\n\n\n`;
27+
}
28+
29+
if (mutatingOps.length > 0) {
30+
body += `--${batchId}\n`;
31+
body += `Content-Type: multipart/mixed; boundary=${changesetId}\n\n`;
32+
let contentIdCounter = 1;
33+
for (const op of mutatingOps) {
34+
body += `--${changesetId}\n`;
35+
body += `Content-Type: application/http\n`;
36+
body += `Content-Transfer-Encoding: binary\n`;
37+
body += `Content-ID: ${op.contentId ?? contentIdCounter++}\n\n`;
38+
body += `${op.method} ${this.http.baseURL}${op.url} HTTP/1.1\n`;
39+
body += `Content-Type: application/json\n\n`;
40+
if (op.body) body += JSON.stringify(op.body);
41+
body += '\n\n';
42+
}
43+
body += `--${changesetId}--\n`;
44+
}
45+
} else {
46+
requests.forEach((req) => {
47+
body += `--${batchId}\n`;
48+
body += `Content-Type: application/http\n`;
49+
body += `Content-Transfer-Encoding: binary\n\n`;
50+
body += `${req.method} ${this.http.baseURL}${req.url} HTTP/1.1\n`;
51+
body += `Content-Type: application/json\n\n`;
52+
if (req.body) body += JSON.stringify(req.body);
53+
body += '\n';
54+
});
55+
}
56+
57+
body += `--${batchId}--`;
58+
59+
const response = await this.requestWithRetry(() =>
60+
this.http.post('$batch', body, {
61+
headers: { 'Content-Type': `multipart/mixed;boundary=${batchId}` },
62+
responseType: 'text',
63+
})
64+
);
65+
66+
try {
67+
const contentType = (response.headers['content-type'] as string | undefined) ?? '';
68+
const boundaryMatch = contentType.match(/boundary=(?:"([^"]+)"|([^;"\s]+))/);
69+
const responseBoundary = boundaryMatch?.[1] ?? boundaryMatch?.[2];
70+
71+
if (!responseBoundary) {
72+
process.stderr.write('[batchExecute] No multipart boundary in response Content-Type; returning raw data.\n');
73+
return [response.data];
74+
}
75+
76+
return parseMultipartResponse(response.data as string, responseBoundary);
77+
} catch (err) {
78+
process.stderr.write(`[batchExecute] Failed to parse multipart response; returning raw data. ${String(err)}\n`);
79+
return [response.data];
80+
}
81+
}
82+
}

src/dataverse/dataverse-client.ts

Lines changed: 1 addition & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,8 @@ import type {
55
EntityMetadata,
66
RelationshipMetadata,
77
WhoAmIResponse,
8-
BatchRequest,
98
} from './types.js';
10-
import { esc, parseMultipartResponse } from './dataverse-client.utils.js';
9+
import { esc } from './dataverse-client.utils.js';
1110

1211
const API_VERSION = '9.2';
1312

@@ -364,77 +363,4 @@ export class DataverseClient {
364363
);
365364
}
366365

367-
// ─── Batch ───────────────────────────────────────────────────────────────
368-
369-
async batchExecute(requests: BatchRequest[], useChangeset = false): Promise<unknown[]> {
370-
const batchId = `batch_${Date.now()}`;
371-
let body = '';
372-
373-
if (useChangeset) {
374-
const changesetId = `changeset_${Date.now() + 1}`;
375-
const getOps = requests.filter(r => r.method === 'GET');
376-
const mutatingOps = requests.filter(r => r.method !== 'GET');
377-
378-
for (const req of getOps) {
379-
body += `--${batchId}\n`;
380-
body += `Content-Type: application/http\n`;
381-
body += `Content-Transfer-Encoding: binary\n\n`;
382-
body += `${req.method} ${this.authProvider.environmentUrl}/api/data/v${API_VERSION}/${req.url} HTTP/1.1\n`;
383-
body += `Accept: application/json\n\n\n`;
384-
}
385-
386-
if (mutatingOps.length > 0) {
387-
body += `--${batchId}\n`;
388-
body += `Content-Type: multipart/mixed; boundary=${changesetId}\n\n`;
389-
let contentIdCounter = 1;
390-
for (const op of mutatingOps) {
391-
body += `--${changesetId}\n`;
392-
body += `Content-Type: application/http\n`;
393-
body += `Content-Transfer-Encoding: binary\n`;
394-
body += `Content-ID: ${op.contentId ?? contentIdCounter++}\n\n`;
395-
body += `${op.method} ${this.authProvider.environmentUrl}/api/data/v${API_VERSION}/${op.url} HTTP/1.1\n`;
396-
body += `Content-Type: application/json\n\n`;
397-
if (op.body) body += JSON.stringify(op.body);
398-
body += '\n\n';
399-
}
400-
body += `--${changesetId}--\n`;
401-
}
402-
} else {
403-
requests.forEach((req) => {
404-
body += `--${batchId}\n`;
405-
body += `Content-Type: application/http\n`;
406-
body += `Content-Transfer-Encoding: binary\n\n`;
407-
body += `${req.method} ${this.authProvider.environmentUrl}/api/data/v${API_VERSION}/${req.url} HTTP/1.1\n`;
408-
body += `Content-Type: application/json\n\n`;
409-
if (req.body) body += JSON.stringify(req.body);
410-
body += '\n';
411-
});
412-
}
413-
414-
body += `--${batchId}--`;
415-
416-
const response = await this.requestWithRetry(() =>
417-
this.http.post('$batch', body, {
418-
headers: { 'Content-Type': `multipart/mixed;boundary=${batchId}` },
419-
responseType: 'text',
420-
})
421-
);
422-
423-
try {
424-
const contentType = (response.headers['content-type'] as string | undefined) ?? '';
425-
const boundaryMatch = contentType.match(/boundary=(?:"([^"]+)"|([^;"\s]+))/);
426-
const responseBoundary = boundaryMatch?.[1] ?? boundaryMatch?.[2];
427-
428-
if (!responseBoundary) {
429-
process.stderr.write('[batchExecute] No multipart boundary in response Content-Type; returning raw data.\n');
430-
return [response.data];
431-
}
432-
433-
return parseMultipartResponse(response.data as string, responseBoundary);
434-
} catch (err) {
435-
process.stderr.write(`[batchExecute] Failed to parse multipart response; returning raw data. ${String(err)}\n`);
436-
return [response.data];
437-
}
438-
}
439-
440366
}

src/dataverse/http-client.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export interface HttpResponse<T = unknown> {
2323
}
2424

2525
export class HttpClient {
26-
private readonly baseURL: string;
26+
readonly baseURL: string;
2727
private readonly timeoutMs: number;
2828
readonly defaultHeaders: Record<string, string>;
2929
private readonly tokenProvider: (() => Promise<string>) | undefined;

src/tools/batch.tools.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { z } from 'zod';
2-
import type { DataverseClient } from '../dataverse/dataverse-client.js';
2+
import type { DataverseBatchClient } from '../dataverse/dataverse-client.batch.js';
33
import type { BatchRequest } from '../dataverse/types.js';
44

55
export const batchTools = [
@@ -73,7 +73,7 @@ const BatchExecuteInput = z.object({
7373
export async function handleBatchTool(
7474
name: string,
7575
args: unknown,
76-
client: DataverseClient
76+
client: DataverseBatchClient
7777
): Promise<{ content: Array<{ type: 'text'; text: string }> }> {
7878
if (name === 'dataverse_batch_execute') {
7979
const { requests, useChangeset } = BatchExecuteInput.parse(args);

tests/unit/batch-tools.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { handleBatchTool } from '../../src/tools/batch.tools.js';
2-
import type { DataverseClient } from '../../src/dataverse/dataverse-client.js';
2+
import type { DataverseBatchClient } from '../../src/dataverse/dataverse-client.batch.js';
33

44
describe('handleBatchTool', () => {
55
let mockClient: Record<string, jest.Mock>;
@@ -10,7 +10,7 @@ describe('handleBatchTool', () => {
1010
};
1111
});
1212

13-
const client = () => mockClient as unknown as DataverseClient;
13+
const client = () => mockClient as unknown as DataverseBatchClient;
1414

1515
// ── dataverse_batch_execute – success ─────────────────────────────────────
1616

tests/unit/dataverse-client.test.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ jest.mock('../../src/dataverse/http-client.js', () => {
1818

1919
import { HttpClient, HttpError } from '../../src/dataverse/http-client.js';
2020
import { DataverseClient } from '../../src/dataverse/dataverse-client.js';
21+
import { DataverseBatchClient } from '../../src/dataverse/dataverse-client.batch.js';
2122
import { DataverseMetadataClient } from '../../src/dataverse/dataverse-client.metadata.js';
2223
import type { AuthProvider } from '../../src/auth/auth-provider.interface.js';
2324

@@ -542,7 +543,7 @@ describe('DataverseClient', () => {
542543
headers: { 'content-type': `multipart/mixed;boundary=${boundary}` },
543544
});
544545

545-
const client = new DataverseClient(mockAuthProvider);
546+
const client = new DataverseBatchClient(mockAuthProvider);
546547
const results = await client.batchExecute([{ method: 'GET', url: 'accounts' }]);
547548

548549
expect(results).toHaveLength(1);
@@ -554,7 +555,7 @@ describe('DataverseClient', () => {
554555
headers: { 'content-type': 'text/plain' },
555556
});
556557

557-
const client = new DataverseClient(mockAuthProvider);
558+
const client = new DataverseBatchClient(mockAuthProvider);
558559
const results = await client.batchExecute([{ method: 'GET', url: 'accounts' }]);
559560

560561
expect(results).toEqual(['raw response']);
@@ -567,7 +568,7 @@ describe('DataverseClient', () => {
567568
headers: { 'content-type': 'multipart/mixed;boundary=test' },
568569
});
569570

570-
const client = new DataverseClient(mockAuthProvider);
571+
const client = new DataverseBatchClient(mockAuthProvider);
571572
// null body will cause parseMultipartResponse to throw; should fallback gracefully
572573
const results = await client.batchExecute([{ method: 'GET', url: 'accounts' }]);
573574

0 commit comments

Comments
 (0)