Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/curly-ways-help.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@transcend-io/mcp-server-base": patch
"@transcend-io/mcp-server-dsr": patch
---

Adds pagination to list indentifiers tool
14 changes: 12 additions & 2 deletions packages/mcp/mcp-server-base/src/clients/rest-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -344,12 +344,22 @@ export class TranscendRestClient {
return response.arrayBuffer();
}

async listRequestIdentifiers(requestId: string): Promise<Record<string, string>[]> {
async listRequestIdentifiers(
requestId: string,
options?: {
/** Maximum number of identifiers to return (default 50) */
first?: number;
/** Zero-based offset for pagination */
offset?: number;
},
): Promise<Record<string, string>[]> {
const first = Math.min(options?.first ?? 50, 100);
const offset = options?.offset ?? 0;
const response = await this.makeRequest<{ identifiers: Record<string, string>[] }>(
'/v1/request-identifiers',
{
method: 'POST',
body: JSON.stringify({ requestId }),
body: JSON.stringify({ requestId, first, offset }),
},
);
return response.identifiers || [];
Expand Down
29 changes: 28 additions & 1 deletion packages/mcp/mcp-server-base/tests/rest-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ describe('TranscendRestClient Sombra host and headers', () => {
);
});

it('listRequestIdentifiers POSTs requestId in the body', async () => {
it('listRequestIdentifiers POSTs requestId with default pagination in the body', async () => {
const mockFetch = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ identifiers: [{ email: 'a@b.com' }] }), {
status: 200,
Expand All @@ -147,6 +147,33 @@ describe('TranscendRestClient Sombra host and headers', () => {
expect(init.method).toBe('POST');
expect(JSON.parse(init.body)).toEqual({
requestId: 'd6e2445a-32d2-4c35-9aa5-9e80cb8e3f89',
first: 50,
offset: 0,
});
});

it('listRequestIdentifiers POSTs custom first and offset', async () => {
const mockFetch = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ identifiers: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
);
vi.stubGlobal('fetch', mockFetch);

const client = new TranscendRestClient(TEST_AUTH, {
baseUrl: 'https://sombra.example.com',
});
await client.listRequestIdentifiers('d6e2445a-32d2-4c35-9aa5-9e80cb8e3f89', {
first: 10,
offset: 20,
});

const [, init] = mockFetch.mock.calls[0]!;
expect(JSON.parse(init.body)).toEqual({
requestId: 'd6e2445a-32d2-4c35-9aa5-9e80cb8e3f89',
first: 10,
offset: 20,
});
});

Expand Down
21 changes: 11 additions & 10 deletions packages/mcp/mcp-server-dsr/src/tools/dsr_list_identifiers.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,33 @@
import {
createListResult,
defineTool,
PaginationSchema,
OffsetPaginationSchema,
type ToolClients,
z,
} from '@transcend-io/mcp-server-base';

export const listIdentifiersSchema = z
.object({
requestId: z.string().describe('ID of the DSR'),
})
.merge(PaginationSchema);
export const listIdentifiersSchema = OffsetPaginationSchema.extend({
requestId: z.string().describe('ID of the DSR'),
});
export type ListIdentifiersInput = z.infer<typeof listIdentifiersSchema>;

export function createDsrListIdentifiersTool(clients: ToolClients) {
const { rest } = clients;

return defineTool({
name: 'dsr_list_identifiers',
description: 'List all identifiers attached to a Data Subject Request',
description: 'List decrypted identifiers attached to a Data Subject Request.',
category: 'DSR Automation',
readOnly: true,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
requireSombra: true,
zodSchema: listIdentifiersSchema,
handler: async ({ requestId }) => {
const identifiers = await rest.listRequestIdentifiers(requestId);
return createListResult(identifiers);
handler: async ({ requestId, first, offset }) => {
const identifiers = await rest.listRequestIdentifiers(requestId, { first, offset });

const hasNextPage = identifiers.length === first;

return createListResult(identifiers, { hasNextPage });
},
});
}
64 changes: 62 additions & 2 deletions packages/mcp/mcp-server-dsr/tests/dsr.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ describe('DSR Tools', () => {
submitDSR: ReturnType<typeof vi.fn>;
pollDSRStatus: ReturnType<typeof vi.fn>;
downloadKeys: ReturnType<typeof vi.fn>;
listDSRIdentifiers: ReturnType<typeof vi.fn>;
listRequestIdentifiers: ReturnType<typeof vi.fn>;
enrichIdentifiers: ReturnType<typeof vi.fn>;
respondAccess: ReturnType<typeof vi.fn>;
respondErasure: ReturnType<typeof vi.fn>;
Expand All @@ -46,7 +46,7 @@ describe('DSR Tools', () => {
submitDSR: vi.fn(),
pollDSRStatus: vi.fn(),
downloadKeys: vi.fn(),
listDSRIdentifiers: vi.fn(),
listRequestIdentifiers: vi.fn(),
enrichIdentifiers: vi.fn(),
respondAccess: vi.fn(),
respondErasure: vi.fn(),
Expand Down Expand Up @@ -145,6 +145,66 @@ describe('DSR Tools', () => {
});
});

describe('dsr_list_identifiers', () => {
it('zodSchema rejects when requestId is missing', () => {
const tools = getTools();
const tool = tools.find((t) => t.name === 'dsr_list_identifiers')!;

const result = tool.zodSchema.safeParse({});
expect(result.success).toBe(false);
expect((result as any).error.issues[0].path).toEqual(['requestId']);
});

it('returns identifiers with pagination metadata on success', async () => {
const identifiers = [
{ id: 'ri-1', name: 'email', value: 'a@b.com', type: 'email' },
{ id: 'ri-2', name: 'phone', value: '+1123123123', type: 'phone' },
];
mockRest.listRequestIdentifiers.mockResolvedValue(identifiers);

const tools = getTools();
const tool = tools.find((t) => t.name === 'dsr_list_identifiers')!;

expect(tool.requireSombra).toBe(true);

const result = await tool.handler({
requestId: 'req-1',
first: 2,
offset: 0,
});

expect(result).toMatchObject({
success: true,
data: identifiers,
hasNextPage: true,
});
expect(mockRest.listRequestIdentifiers).toHaveBeenCalledWith('req-1', {
first: 2,
offset: 0,
});
});

it('sets hasNextPage false when page is shorter than first', async () => {
mockRest.listRequestIdentifiers.mockResolvedValue([
{ id: 'ri-1', name: 'email', value: 'a@b.com', type: 'email' },
]);

const tools = getTools();
const tool = tools.find((t) => t.name === 'dsr_list_identifiers')!;

const result = await tool.handler({
requestId: 'req-1',
first: 50,
offset: 0,
});

expect(result).toMatchObject({
success: true,
hasNextPage: false,
});
});
});

describe('dsr_list_request_data_silos', () => {
it('zodSchema rejects when requestId is missing', () => {
const tools = getTools();
Expand Down
Loading