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
53 changes: 21 additions & 32 deletions ui/user/src/lib/components/vmcps/ConnectVMcp.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@
vmcpComponentId,
vmcpConnectURL,
vmcpMissingStaticOAuthComponent,
vmcpInstanceNeedsUserConfiguration,
type OAuthReturnParam
} from '$lib/services/vmcps/utils';
import { profile, vmcpInstances } from '$lib/stores';
Expand All @@ -32,6 +31,7 @@
import { fade } from 'svelte/transition';
import { twMerge } from 'tailwind-merge';

let { reconciliationRetryMs = 1000 }: { reconciliationRetryMs?: number } = $props();
let vmcp = $state<VMCP>();
let instance = $state<VMCPInstance>();
let connectDialog = $state<ReturnType<typeof ResponsiveDialog>>();
Expand Down Expand Up @@ -276,41 +276,32 @@
return { components };
}

function configuredInstance(submitted: VMCPInstance): VMCPInstance {
return {
...submitted,
status: {
...submitted.status,
configured: true,
...(vmcpInstanceNeedsUserConfiguration(submitted)
? { missingRequiredConfiguration: [] }
: {})
}
};
function instanceConfigurationSettled(candidate: VMCPInstance) {
const status = candidate.status;
return status?.configured === true && status?.configurationCheckHash;
}

async function refreshConfiguredInstance(submitted: VMCPInstance): Promise<VMCPInstance> {
const optimistic = configuredInstance(submitted);
vmcpInstances.upsert(optimistic);
if (!vmcpInstanceNeedsUserConfiguration(submitted)) {
return optimistic;
}
async function waitForReconciledInstance(submitted: VMCPInstance): Promise<VMCPInstance> {
const maxAttempts = 5;
let latest = submitted;

for (let attempt = 0; attempt < 8; attempt++) {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
if (attempt > 0) {
await new Promise((resolve) => setTimeout(resolve, 250));
await new Promise((resolve) => setTimeout(resolve, reconciliationRetryMs));
}
try {
const latest = await UserService.getVMCPInstance(submitted.id, { dontLogErrors: true });
if (!vmcpInstanceNeedsUserConfiguration(latest)) {
latest = await UserService.getVMCPInstance(submitted.id, { dontLogErrors: true });
if (instanceConfigurationSettled(latest)) {
vmcpInstances.upsert(latest);
return latest;
}
} catch {
// Status is reconciled asynchronously after configure.
// Status is reconciled asynchronously after create or configure.
}
}
return optimistic;

vmcpInstances.upsert(latest);
return latest;
}

function initUpdatingOrLaunchProgress() {
Expand Down Expand Up @@ -430,24 +421,22 @@
const { timeout1, timeout2, timeout3 } = initUpdatingOrLaunchProgress();
try {
const targetInstance = instance ?? (await UserService.createVMCPInstance(target.id));
if (configureForm) {
instance = await refreshConfiguredInstance(
await UserService.configureVMCPInstance(
const submitted = configureForm
? await UserService.configureVMCPInstance(
targetInstance.id,
configurationPayload(configureForm)
)
);
} else {
instance = targetInstance;
vmcpInstances.upsert(instance);
}
: targetInstance;
instance = await waitForReconciledInstance(submitted);
Comment thread
ivyjeong13 marked this conversation as resolved.

const launchResponse = await UserService.validateSingleOrRemoteMcpServerLaunched(target.id);
if (!launchResponse.success) {
launchError = launchResponse.message ?? m.vmcps_failed_to_launch();
return;
}

instance = await UserService.getVMCPInstance(submitted.id);
vmcpInstances.upsert(instance);
await verifyOauthOrConnect();
} catch (err) {
launchError = err instanceof Error ? err.message : m.vmcps_failed_to_launch();
Expand Down
132 changes: 128 additions & 4 deletions ui/user/src/lib/components/vmcps/ConnectVMcp.svelte.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ function configurableVMcp(): VMCP {
async function renderDialog(vmcp: VMCP, instance?: VMCPInstance, options?: VMcpConnectOptions) {
await preparePageData();
await vmcpInstances.refresh();
const result = await render(ConnectVMcp);
const result = await render(ConnectVMcp, { reconciliationRetryMs: 100 });
result.component.open(vmcp, instance, options);
return result;
}
Expand Down Expand Up @@ -96,6 +96,84 @@ describe('ConnectVMcp.svelte', () => {
await expect.element(page.getByCSS('#connect-to-vmcp-dialog')).not.toBeVisible();
});

function createdInstance(vmcp: VMCP, status?: VMCPInstance['status']): VMCPInstance {
return {
id: 'vmcpi-created',
vmcpID: vmcp.id,
userID: getProfileResponse.id,
created: '2026-01-01T00:00:00Z',
status
};
}

it('opens the inspector only after the instance reports configured', async () => {
const vmcp = createVMCP({ id: 'vmcp1delayed', displayName: 'Plain vMCP' });
mockConfigureAndLaunch(vmcp);
let reads = 0;
let finishReconciliation!: () => void;
const reconciliation = new Promise<void>((resolve) => {
finishReconciliation = resolve;
});
worker.use(
http.get('/api/vmcp-instances/vmcpi-created', async () => {
reads += 1;
if (reads >= 3) await reconciliation;
return HttpResponse.json(
createdInstance(
vmcp,
reads >= 3 ? { configured: true, configurationCheckHash: 'checked' } : undefined
)
);
})
);

await renderDialog(vmcp);
await page.getByRole('button', { name: 'Test vMCP' }).click();
await continueFromIntro();

await vi.waitFor(() => expect(reads).toBe(3));
expect(vi.mocked(goto)).not.toHaveBeenCalled();
finishReconciliation();

await vi.waitFor(
() => {
expect(vi.mocked(goto)).toHaveBeenCalledWith(`/vmcps/${vmcp.id}?view=inspector`);
},
{ timeout: 4000 }
);
expect(reads).toBe(4);
expect(vmcpInstances.current.items[0]?.status?.configured).toBe(true);
}, 8000);

it('retries when the configuration check reports an incomplete instance', async () => {
const vmcp = createVMCP({ id: 'vmcp1unconfigured', displayName: 'Plain vMCP' });
mockConfigureAndLaunch(vmcp);
const getInstance = vi.fn();
worker.use(
http.get('/api/vmcp-instances/vmcpi-created', () => {
getInstance();
return HttpResponse.json(
createdInstance(vmcp, {
configured: false,
configurationCheckHash: 'checked'
})
);
})
);

await renderDialog(vmcp);
await page.getByRole('button', { name: 'Test vMCP' }).click();
await continueFromIntro();

await vi.waitFor(
() => {
expect(vi.mocked(goto)).toHaveBeenCalledWith(`/vmcps/${vmcp.id}?view=inspector`);
},
{ timeout: 7000 }
);
expect(getInstance).toHaveBeenCalledTimes(6);
}, 9000);

it('starts setup from Test and opens the inspector after launch when there is no instance', async () => {
const vmcp = createVMCP({ id: 'vmcp1testlaunch', displayName: 'Plain vMCP' });
const { launch } = mockConfigureAndLaunch(vmcp);
Expand Down Expand Up @@ -245,6 +323,15 @@ describe('ConnectVMcp.svelte', () => {
created: '2026-01-01T00:00:00Z'
});
}),
http.get('/api/vmcp-instances/vmcpi-created', () =>
HttpResponse.json({
id: 'vmcpi-created',
vmcpID: vmcp.id,
userID: getProfileResponse.id,
created: '2026-01-01T00:00:00Z',
status: { configured: true, configurationCheckHash: 'checked' }
})
),
http.post(`/api/vmcps/${vmcp.id}/launch`, async () => {
launch();
if (opts?.launchDelayMs) {
Expand Down Expand Up @@ -311,6 +398,43 @@ describe('ConnectVMcp.svelte', () => {
.toBeVisible();
});

it('refreshes the pending instance after launch before reopening the connect dialog', async () => {
const vmcp = configurableVMcp();
const { launch } = mockConfigureAndLaunch(vmcp);
let reconciled = false;
let reads = 0;
worker.use(
http.get('/api/vmcp-instances/vmcpi-created', () => {
reads += 1;
return HttpResponse.json(
createdInstance(
vmcp,
reconciled ? { configured: true, configurationCheckHash: 'checked' } : undefined
)
);
}),
http.post(`/api/vmcps/${vmcp.id}/launch`, () => {
launch();
reconciled = true;
return HttpResponse.json({});
})
);

const result = await renderDialog(vmcp);
await configureFromPreconfigure();

await vi.waitFor(() => expect(launch).toHaveBeenCalledOnce(), { timeout: 5000 });
await expect.element(page.getByCSS('#connect-to-vmcp-dialog')).toBeVisible();
expect(reads).toBeGreaterThan(5);
expect(vmcpInstances.current.items[0]?.status?.configured).toBe(true);

result.component.open(vmcp);
await page.getByRole('button', { name: 'Test vMCP' }).click();
expect(vi.mocked(goto)).toHaveBeenCalledWith(`/vmcps/${vmcp.id}?view=inspector`);
expect(launch).toHaveBeenCalledOnce();
await expect.element(introSetupText()).not.toBeVisible();
}, 8000);

it('shows launch progress and an error when launch fails', async () => {
const vmcp = configurableVMcp();
const { configureInstance, launch } = mockConfigureAndLaunch(vmcp, {
Expand Down Expand Up @@ -381,7 +505,7 @@ describe('ConnectVMcp.svelte', () => {

await preparePageData();
await vmcpInstances.refresh();
const result = await render(ConnectVMcp);
const result = await render(ConnectVMcp, { reconciliationRetryMs: 100 });
await result.component.openEditConfiguration(vmcp, existing);

await expect.element(page.getByCSS('#connect-to-vmcp-dialog')).not.toBeVisible();
Expand Down Expand Up @@ -415,7 +539,7 @@ describe('ConnectVMcp.svelte', () => {
getInstance();
return HttpResponse.json({
...existing,
status: { configured: true }
status: { configured: true, configurationCheckHash: 'checked' }
});
}),
http.post(`/api/vmcps/${vmcp.id}/launch`, () => HttpResponse.json({})),
Expand All @@ -424,7 +548,7 @@ describe('ConnectVMcp.svelte', () => {

await preparePageData();
vmcpInstances.current = { items: [existing], loading: false };
const result = await render(ConnectVMcp);
const result = await render(ConnectVMcp, { reconciliationRetryMs: 100 });
await result.component.openEditConfiguration(vmcp, existing);

const tokenField = page.getByCSS('input[name="API token"]');
Expand Down
2 changes: 1 addition & 1 deletion ui/user/src/lib/components/vmcps/VMcpCard.svelte.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -597,7 +597,7 @@ describe('VMcpCard.svelte', () => {
http.get('/api/vmcp-instances/vmcpi-1', () =>
HttpResponse.json({
...stale,
status: { configured: true }
status: { configured: true, configurationCheckHash: 'checked' }
})
)
);
Expand Down
3 changes: 2 additions & 1 deletion ui/user/src/lib/components/vmcps/VMcpDesigner.svelte.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2056,7 +2056,7 @@ describe('VMcpDesigner.svelte', () => {
vmcpID: vmcp.id,
userID: getProfileResponse.id,
created: vmcp.created,
status: { configured: true }
status: { configured: true, configurationCheckHash: 'configured' }
};
let authenticated = false;
let created = false;
Expand All @@ -2072,6 +2072,7 @@ describe('VMcpDesigner.svelte', () => {
created = true;
return HttpResponse.json(instance);
}),
http.get('/api/vmcp-instances/:id', () => HttpResponse.json(instance)),
http.get('/api/vmcp-instances', () =>
HttpResponse.json({ items: created ? [instance] : [] })
),
Expand Down
4 changes: 3 additions & 1 deletion ui/user/src/lib/components/vmcps/VMcpTester.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,9 @@
return { key: 'inspector', value: vmcp.id };
}

let launching = $derived(loading || (vmcpInstances.current.loading && !launched));
let launching = $derived(
loading || (vmcpInstances.current.loading && !instance?.status?.configured)
);
let showTester = $derived(
Boolean(instance?.status?.configured) && !sessionStartFailed && !missingOAuthComponent
);
Expand Down
29 changes: 28 additions & 1 deletion ui/user/src/lib/components/vmcps/VMcpTester.svelte.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ async function renderVMcpTester(
onLaunch?: () => void;
openEditInstanceConfiguration?: (target: VMCP, instance: VMCPInstance) => void;
connectFailureStatus?: number;
instancesLoading?: boolean;
tab?: string;
}
) {
Expand All @@ -131,7 +132,10 @@ async function renderVMcpTester(
const instance =
options && 'instance' in options ? options.instance : createInstance({ configured: true });

vmcpInstances.current = { items: instance ? [instance] : [], loading: false };
vmcpInstances.current = {
items: instance ? [instance] : [],
loading: options?.instancesLoading ?? false
};
if (instance) {
if (options?.connectFailureStatus !== undefined) {
mockMCPInitializationFailure(instance.id, options.connectFailureStatus);
Expand Down Expand Up @@ -272,6 +276,29 @@ describe('VMcpTester', () => {
.toBeVisible();
});

it('keeps starting session while an unconfigured instance is still loading', async () => {
const instance = createInstance({
configured: false,
configurationCheckHash: 'checked'
});
await renderVMcpTester({}, { instance, instancesLoading: true });

await expect.element(page.getByText('Starting session...')).toBeVisible();
await expect
.element(
page.getByText('Before you can continue inspecting this vMCP, an update is required.')
)
.not.toBeInTheDocument();

vmcpInstances.current = { items: [instance], loading: false };

await expect
.element(
page.getByText('Before you can continue inspecting this vMCP, an update is required.')
)
.toBeVisible();
});

it('opens edit configuration for an unconfigured instance', async () => {
const instance = createInstance({
configured: false,
Expand Down
2 changes: 2 additions & 0 deletions ui/user/src/lib/services/user/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -661,6 +661,8 @@ export interface VMCPInstanceStatus {
configured?: boolean;
missingRequiredConfiguration?: string[];
userConfigurationHash?: string;
/** Present once the controller has written configuration status. */
configurationCheckHash?: string;
}

export interface VMCPInstance extends VMCPInstanceManifest {
Expand Down
Loading