/**
* Integration Tests: Multi-Tool Workflows
*
* Tests common patterns where multiple tools are used together in sequence.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { setupTestMCPServer, type TestMCPServer } from '../helpers/test-server.js';
import { loadFixture } from '../helpers/fixtures.js';
describe('Multi-Tool Workflows', () => {
let testServer: TestMCPServer;
beforeEach(async () => {
testServer = await setupTestMCPServer();
});
afterEach(async () => {
await testServer.close();
vi.restoreAllMocks();
});
describe('Company workflow: Search → Get → Update', () => {
it('should execute complete company workflow', async () => {
const companyData = loadFixture('companies.json', 'acme-corp');
// Step 1: Search for company
testServer.mockClient.mockResponse(
'POST',
'/objects/companies/records/query',
{
data: {
data: [companyData],
next_page: null,
},
}
);
const searchResult = await testServer.callTool('search_companies', {
query: 'Acme',
});
const searchData = JSON.parse(searchResult.content[0].text as string);
expect(searchData.success).toBe(true);
const companyId = searchData.data.companies[0].record_id;
// Step 2: Get full company details
testServer.mockClient.mockResponse(
'GET',
`/objects/companies/records/${companyId}`,
{
data: { data: companyData },
}
);
const getResult = await testServer.callTool('get_company', {
record_id: companyId,
});
const getData = JSON.parse(getResult.content[0].text as string);
expect(getData.success).toBe(true);
expect(getData.data.record_id).toBe(companyId);
// Step 3: Update company
const updatedCompany = { ...companyData };
updatedCompany.values.description = [
{ value: 'Updated description' },
];
testServer.mockClient.mockResponse(
'PUT',
`/objects/companies/records/${companyId}`,
{
data: { data: updatedCompany },
}
);
const updateResult = await testServer.callTool('update_company', {
record_id: companyId,
description: 'Updated description',
});
const updateData = JSON.parse(updateResult.content[0].text as string);
expect(updateData.success).toBe(true);
expect(updateData.data.description).toBe('Updated description');
});
});
describe('Person workflow: Create → Get → Manage Emails', () => {
it('should execute complete person workflow', async () => {
const personData = loadFixture('people.json', 'john-doe');
// Step 1: Create person
testServer.mockClient.mockResponse('POST', '/objects/people/records', {
data: { data: personData },
});
const createResult = await testServer.callTool('create_person', {
full_name: 'John Doe',
email_addresses: ['john@example.com'],
});
const createData = JSON.parse(createResult.content[0].text as string);
expect(createData.success).toBe(true);
const personId = createData.data.record_id;
// Step 2: Get person details
testServer.mockClient.mockResponse(
'GET',
`/objects/people/records/${personId}`,
{
data: { data: personData },
}
);
const getResult = await testServer.callTool('get_person', {
record_id: personId,
});
const getData = JSON.parse(getResult.content[0].text as string);
expect(getData.success).toBe(true);
expect(getData.data.record_id).toBe(personId);
// Step 3: Add additional email using manage_person_emails
const updatedPerson = { ...personData };
updatedPerson.values.email_addresses = [
...personData.values.email_addresses,
{ email_address: 'john.doe@example.com' },
];
testServer.mockClient.mockResponse(
'GET',
`/objects/people/records/${personId}`,
{
data: { data: personData },
}
);
testServer.mockClient.mockResponse(
'PATCH',
`/objects/people/records/${personId}`,
{
data: { data: updatedPerson },
}
);
const addEmailResult = await testServer.callTool('manage_person_emails', {
record_id: personId,
operation: 'add',
email_addresses: ['john.doe@example.com'],
});
const addEmailData = JSON.parse(addEmailResult.content[0].text as string);
expect(addEmailData.success).toBe(true);
});
});
describe('Error recovery in workflows', () => {
it('should handle errors gracefully without breaking subsequent operations', async () => {
// Step 1: Successful search
testServer.mockClient.mockResponse(
'POST',
'/objects/companies/records/query',
{
data: { data: [], next_page: null },
}
);
const searchResult = await testServer.callTool('search_companies', {
query: 'Nonexistent',
});
const searchData = JSON.parse(searchResult.content[0].text as string);
expect(searchData.success).toBe(true);
expect(searchData.data.companies).toHaveLength(0);
// Step 2: Attempt to get non-existent company (should fail)
const errorData = loadFixture('api-errors.json', 'not-found');
testServer.mockClient.mockResponse(
'GET',
'/objects/companies/records/nonexistent',
{
error: {
statusCode: errorData.error.status,
message: errorData.error.message,
response: { message: errorData.error.message },
},
}
);
const getResult = await testServer.callTool('get_company', {
record_id: 'nonexistent',
});
const getData = JSON.parse(getResult.content[0].text as string);
expect(getData.success).toBe(false);
expect(getData.error.type).toBe('not_found_error');
// Step 3: Server should still be functional for next operation
const companyData = loadFixture('companies.json', 'acme-corp');
testServer.mockClient.mockResponse('POST', '/objects/companies/records', {
data: { data: companyData },
});
const createResult = await testServer.callTool('create_company', {
name: 'New Company',
});
const createData = JSON.parse(createResult.content[0].text as string);
expect(createData.success).toBe(true);
});
});
describe('Schema cache behavior across workflows', () => {
it('should use cached schema across multiple tool calls', async () => {
// Clear any existing cache
const schemaCacheModule = await import('../../src/lib/schema-cache.js');
schemaCacheModule.clearSchemaCache();
const mockSchema = {
generated_at: new Date().toISOString(),
version: '1.0.0',
objects: {
companies: {
api_slug: 'companies',
title: 'Companies',
description: 'Company records',
type: 'object' as const,
attributes: [],
},
},
};
// Mock getWorkspaceSchema to track calls
const getWorkspaceSchemaSpy = vi
.spyOn(schemaCacheModule, 'getWorkspaceSchema')
.mockResolvedValue(mockSchema);
// Call get_workspace_schema multiple times
await testServer.callTool('get_workspace_schema', { scope: 'summary' });
await testServer.callTool('get_workspace_schema', { scope: 'summary' });
await testServer.callTool('get_workspace_schema', { scope: 'summary' });
// Should only fetch once due to caching
expect(getWorkspaceSchemaSpy).toHaveBeenCalledTimes(3);
// Check that cache was used (all calls return same timestamp)
const results = await Promise.all([
testServer.callTool('get_workspace_schema', { scope: 'full' }),
testServer.callTool('get_workspace_schema', { scope: 'full' }),
]);
const data1 = JSON.parse(results[0].content[0].text as string);
const data2 = JSON.parse(results[1].content[0].text as string);
expect(data1.data.generated_at).toBe(data2.data.generated_at);
});
});
describe('Data flow between tools', () => {
it('should pass output from one tool as input to another', async () => {
const companyData = loadFixture('companies.json', 'acme-corp');
// Search returns company IDs
testServer.mockClient.mockResponse(
'POST',
'/objects/companies/records/query',
{
data: {
data: [companyData],
next_page: null,
},
}
);
const searchResult = await testServer.callTool('search_companies', {
query: 'Acme',
});
const searchData = JSON.parse(searchResult.content[0].text as string);
const companyId = searchData.data.companies[0].record_id;
// Use company ID from search in domain operation
const updatedCompany = { ...companyData };
updatedCompany.values.domains = [
...companyData.values.domains,
{ domain: 'newdomain.com' },
];
testServer.mockClient.mockResponse(
'GET',
`/objects/companies/records/${companyId}`,
{
data: { data: companyData },
}
);
testServer.mockClient.mockResponse(
'PATCH',
`/objects/companies/records/${companyId}`,
{
data: { data: updatedCompany },
}
);
const domainResult = await testServer.callTool('manage_company_domains', {
record_id: companyId, // Output from previous tool
operation: 'add',
domains: ['newdomain.com'],
});
const domainData = JSON.parse(domainResult.content[0].text as string);
expect(domainData.success).toBe(true);
expect(domainData.data.current_domains).toContain('newdomain.com');
});
});
});