diff --git a/client/src/components/Planner/TransitSearchPanel.test.tsx b/client/src/components/Planner/TransitSearchPanel.test.tsx
index 7169c3afc..23bbc559a 100644
--- a/client/src/components/Planner/TransitSearchPanel.test.tsx
+++ b/client/src/components/Planner/TransitSearchPanel.test.tsx
@@ -91,6 +91,27 @@ beforeEach(() => {
})
describe('TransitSearchPanel', () => {
+ it.each(['google', 'transitous'] as const)('updates attribution when a later search returns %s fallback routes', async (provider) => {
+ const user = userEvent.setup()
+ const onAdd = vi.fn().mockResolvedValue({})
+ const previous = provider === 'google' ? 'transitous' : 'google'
+ transitApiMock.plan
+ .mockResolvedValueOnce({ itineraries: [ITINERARY], provider: previous })
+ .mockResolvedValueOnce({ itineraries: [ITINERARY], provider })
+ render()
+ await pickFromAndTo(user)
+ await user.click(screen.getByRole('button', { name: /^Search$/ }))
+ await screen.findByText(previous === 'google' ? 'Google Maps' : 'Transitous')
+ await user.click(screen.getByRole('button', { name: /^Search$/ }))
+ expect(await screen.findByText(provider === 'google' ? 'Google Maps' : 'Transitous')).toBeInTheDocument()
+ expect(screen.queryByText(previous === 'google' ? 'Google Maps' : 'Transitous')).not.toBeInTheDocument()
+ await user.click(await screen.findByText(/08:30 – 09:00/))
+ await user.click(await screen.findByRole('button', { name: 'Add to day' }))
+ await waitFor(() => expect(onAdd).toHaveBeenCalledWith(expect.objectContaining({
+ metadata: expect.objectContaining({ transit: expect.objectContaining({ provider }) }),
+ })))
+ })
+
it('FE-PLANNER-TRANSIT-001: renders from/to pickers, modes and preferences', () => {
render()
expect(screen.getAllByPlaceholderText('Search stop or station…')).toHaveLength(2)
diff --git a/server/src/nest/transit/google-transit.provider.ts b/server/src/nest/transit/google-transit.provider.ts
index a749dc160..3ad4600dc 100644
--- a/server/src/nest/transit/google-transit.provider.ts
+++ b/server/src/nest/transit/google-transit.provider.ts
@@ -268,6 +268,11 @@ export class GoogleTransitProvider {
return !!(await this.resolveKey(userId)).key;
}
+ /** Fallback may bill only an operator or instance key, never a personal key. */
+ async isAvailableForFallback(): Promise {
+ return !!(await this.resolveKey(0)).key;
+ }
+
private async call(
endpoint: string,
label: string,
diff --git a/server/src/nest/transit/transit.mcp.ts b/server/src/nest/transit/transit.mcp.ts
index 9213f6b88..9d4e9a699 100644
--- a/server/src/nest/transit/transit.mcp.ts
+++ b/server/src/nest/transit/transit.mcp.ts
@@ -22,7 +22,7 @@ import {
} from './transit-itinerary.helpers';
import { SCHEDULED_TRANSIT_MODES, type TransitItinerary } from './transit.helpers';
import { TransitService } from './transit.service';
-import { idSchema } from '@trek/shared';
+import { adminTransitProviderRequestSchema, idSchema, type TransitProvider } from '@trek/shared';
import { z } from 'zod';
@@ -178,13 +178,16 @@ export class TransitMcp {
@Tool({
name: 'create_transit_journey',
description:
- 'Add one itinerary returned by search_transit_routes to a trip day as a first-class automated public-transit journey.',
+ 'Add one itinerary returned by search_transit_routes to a trip day as a first-class automated public-transit journey. Pass the search response provider unchanged so fallback routes retain their source.',
inputSchema: {
tripId: idSchema,
dayId: idSchema.describe('Trip day on which the journey departs'),
from: transitPlaceInput,
to: transitPlaceInput,
itinerary: transitItinerarySchema,
+ provider: adminTransitProviderRequestSchema.shape.provider
+ .optional()
+ .describe('Provider returned by search_transit_routes'),
notes: z.string().max(1000).optional(),
},
annotations: TOOL_ANNOTATIONS_OPEN_WORLD_NON_IDEMPOTENT,
@@ -197,6 +200,7 @@ export class TransitMcp {
from,
to,
itinerary,
+ provider,
notes,
}: {
tripId: number;
@@ -206,6 +210,7 @@ export class TransitMcp {
// The schema validates exactly the wire TransitItinerary shape; its
// z.infer prints looser optionals, so name the contract type directly.
itinerary: TransitItinerary;
+ provider?: TransitProvider;
notes?: string;
},
ctx: McpContext,
@@ -228,11 +233,11 @@ export class TransitMcp {
isError: true,
};
}
- // The itinerary came from search_transit_routes, which the active backend answered.
- const provider = await this.transit.activeProvider(ctx.userId);
+ // New callers carry the actual search source; omission keeps legacy callers working.
+ const itineraryProvider = provider ?? (await this.transit.activeProvider(ctx.userId));
let reservationParts: ReturnType;
try {
- reservationParts = buildTransitReservationParts(from, to, cleaned, provider);
+ reservationParts = buildTransitReservationParts(from, to, cleaned, itineraryProvider);
} catch (err) {
return errorResult(err, 'Unable to resolve the transit journey timezones.');
}
diff --git a/server/src/nest/transit/transit.service.ts b/server/src/nest/transit/transit.service.ts
index ff105d5e5..69eb91098 100644
--- a/server/src/nest/transit/transit.service.ts
+++ b/server/src/nest/transit/transit.service.ts
@@ -25,8 +25,8 @@ import type { TransitProvider } from '@trek/shared';
* regions Transitous has no feed for. This class stays the entry point and the
* validator either way: the request is checked here, then dispatched, so both
* backends are held to the same coordinate/mode/transfer contract and answer in
- * the same compact shape. Transitous remains the default and the fallback —
- * see GoogleTransitProvider.isActive.
+ * the same compact shape. Transitous remains the default. Empty plans try the
+ * other backend when available; Google fallback requires an admin-set key.
*
* Both responses name the backend that answered. The fallback is silent by
* design, so without this an empty result is indistinguishable between "this
@@ -159,7 +159,7 @@ export class TransitService {
@Inject(transitConfig.KEY) private readonly transitEnv: ConfigType,
) {}
- /** The backend that answers this user's searches: Google where it is set up and keyed, Transitous otherwise. */
+ /** Configured primary for this user; an empty plan may be answered by the other provider. */
async activeProvider(userId = 0): Promise {
return (await this.google.isActive(userId)) ? 'google' : 'transitous';
}
@@ -264,10 +264,33 @@ export class TransitService {
// After validation on purpose: whichever backend answers, the caller is held
// to the same coordinate/mode/transfer contract and gets the same 400s.
- if ((await this.activeProvider(userId)) === 'google') {
- return { ...(await this.google.plan(q, language, userId)), provider: 'google' };
- }
+ const googlePrimary = (await this.activeProvider(userId)) === 'google';
+ const primary = googlePrimary
+ ? { ...(await this.google.plan(q, language, userId)), provider: 'google' as const }
+ : await this.planTransitous(params);
+ if (primary.itineraries.length > 0) return primary;
+ // An empty successful response alone triggers fallback. Primary errors keep
+ // propagating; a secondary outage must not replace a valid empty response.
+ try {
+ if (googlePrimary) {
+ const secondary = await this.planTransitous(params);
+ if (secondary.itineraries.length > 0) return secondary;
+ } else if (await this.google.isAvailableForFallback()) {
+ // Resolve again without a user id so a changed instance key can never
+ // cause the fallback to bill the caller's personal key.
+ const secondary = await this.google.plan(q, language, 0);
+ if (secondary.itineraries.length > 0) return { ...secondary, provider: 'google' };
+ }
+ } catch {
+ // Keep the primary's empty result and its provider attribution.
+ }
+ return primary;
+ }
+
+ private async planTransitous(
+ params: URLSearchParams,
+ ): Promise<{ itineraries: TransitItinerary[]; provider: TransitProvider }> {
const key = `plan:${params.toString()}`;
const cached = cacheGet(key);
if (cached) return { ...(cached as { itineraries: TransitItinerary[] }), provider: 'transitous' };
diff --git a/server/tests/e2e/transit-provider.e2e.test.ts b/server/tests/e2e/transit-provider.e2e.test.ts
index 86948abf5..f103d14c0 100644
--- a/server/tests/e2e/transit-provider.e2e.test.ts
+++ b/server/tests/e2e/transit-provider.e2e.test.ts
@@ -11,6 +11,7 @@ import { db } from '../../src/db/database';
import { AppSettings } from '../../src/db/entities/AppSettings.entity';
import { Users } from '../../src/db/entities/Users.entity';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
+import { GoogleQuotaService } from '../../src/nest/google-quota/google-quota.service';
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
import { clearGoogleTransitCache } from '../../src/nest/transit/google-transit.provider';
import { TransitModule } from '../../src/nest/transit/transit.module';
@@ -75,6 +76,7 @@ describe('Transit backend switch e2e (#1699)', () => {
});
beforeEach(async () => {
+ vi.restoreAllMocks();
vi.stubGlobal('fetch', fetchMock);
fetchMock.mockReset();
// MOTIS and Routes shapes both parse as "no itineraries", which is all this
@@ -137,4 +139,101 @@ describe('Transit backend switch e2e (#1699)', () => {
expect(await planUpstream()).toBe('https://routes.googleapis.com/directions/v2:computeRoutes');
expect(fetchMock.mock.calls[0][1].headers['X-Goog-Api-Key']).toBe('personal-key');
});
+
+ const motisRoute = {
+ itineraries: [
+ {
+ startTime: '2026-09-10T09:00:00Z',
+ endTime: '2026-09-10T09:08:00Z',
+ legs: [
+ {
+ mode: 'SUBWAY',
+ duration: 480,
+ from: { name: 'A', lat: 34.69, lon: 135.49 },
+ to: { name: 'B', lat: 34.68, lon: 135.51 },
+ },
+ ],
+ },
+ ],
+ };
+ const googleRoute = {
+ routes: [
+ {
+ legs: [
+ {
+ steps: [
+ {
+ travelMode: 'TRANSIT',
+ staticDuration: '480s',
+ transitDetails: {
+ stopDetails: {
+ departureStop: { name: 'A', location: { latLng: { latitude: 34.69, longitude: 135.49 } } },
+ arrivalStop: { name: 'B', location: { latLng: { latitude: 34.68, longitude: 135.51 } } },
+ departureTime: '2026-09-10T09:00:00Z',
+ arrivalTime: '2026-09-10T09:08:00Z',
+ },
+ transitLine: { vehicle: { type: 'SUBWAY' } },
+ },
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ };
+ const okJson = (data: unknown) => ({ ok: true, status: 200, headers: { get: () => null }, json: async () => data });
+
+ describe.each(['google', 'transitous'] as const)('%s primary fallback', (primary) => {
+ it.each(['success', 'primary-success', 'empty', 'secondary-error', 'primary-error'])('%s', async (outcome) => {
+ await setAppSetting(orm, 'transit_provider', primary);
+ await setAppSetting(orm, 'maps_api_key', 'instance-key');
+ const secondary = primary === 'google' ? 'transitous' : 'google';
+ fetchMock.mockImplementation(async (url: string) => {
+ const provider = String(url).includes('googleapis') ? 'google' : 'transitous';
+ if (
+ (outcome === 'primary-error' && provider === primary) ||
+ (outcome === 'secondary-error' && provider === secondary)
+ ) {
+ return { ok: false, status: 500, headers: { get: () => null }, json: async () => ({}) };
+ }
+ const hasRoutes =
+ (outcome === 'success' && provider === secondary) || (outcome === 'primary-success' && provider === primary);
+ return okJson(hasRoutes ? (provider === 'google' ? googleRoute : motisRoute) : {});
+ });
+ const res = await request(server).get(nextPlanUrl()).set('Cookie', sessionCookie(ADMIN));
+ expect(res.status).toBe(outcome === 'primary-error' ? 502 : 200);
+ if (outcome !== 'primary-error') {
+ expect(res.body.provider).toBe(outcome === 'success' ? secondary : primary);
+ expect(res.body.itineraries).toHaveLength(['success', 'primary-success'].includes(outcome) ? 1 : 0);
+ }
+ expect(fetchMock).toHaveBeenCalledTimes(outcome.startsWith('primary-') ? 1 : 2);
+ expect(String(fetchMock.mock.calls[0][0]).includes('googleapis')).toBe(primary === 'google');
+ if (secondary === 'google' && outcome === 'success') {
+ expect(fetchMock.mock.calls[1][1].headers['X-Goog-Api-Key']).toBe('instance-key');
+ }
+ });
+ });
+
+ it.each(['absent', 'personal-only', 'quota-exhausted'])('does not fall back to Google when %s', async (state) => {
+ if (state === 'personal-only') await updateRows(orm, Users, { id: ADMIN }, { maps_api_key: 'personal-key' });
+ if (state === 'quota-exhausted') {
+ await setAppSetting(orm, 'maps_api_key', 'instance-key');
+ vi.spyOn(app.get(GoogleQuotaService), 'exhausted').mockResolvedValue(true);
+ }
+ await planUpstream();
+ expect(lastBody.provider).toBe('transitous');
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('uses the operator key for Google fallback', async () => {
+ vi.stubEnv('PLACES_API_KEY', 'operator-key');
+ try {
+ fetchMock.mockResolvedValueOnce(okJson({})).mockResolvedValueOnce(okJson(googleRoute));
+ await planUpstream();
+ expect(lastBody.provider).toBe('google');
+ expect(fetchMock.mock.calls[1][1].headers['X-Goog-Api-Key']).toBe('operator-key');
+ } finally {
+ vi.unstubAllEnvs();
+ }
+ });
});
diff --git a/server/tests/unit/mcp/tools-transit.test.ts b/server/tests/unit/mcp/tools-transit.test.ts
index 507b1233a..de7e8eacf 100644
--- a/server/tests/unit/mcp/tools-transit.test.ts
+++ b/server/tests/unit/mcp/tools-transit.test.ts
@@ -146,6 +146,21 @@ async function withHarness(userId: number, scopes: string[] | null, fn: (harness
}
describe('MCP transit tools', () => {
+ it.each(['google', 'transitous'] as const)('preserves the %s route provider', async (provider) => {
+ const { user } = createUser(testDb);
+ planMock.mockResolvedValue({ itineraries: [itinerary], provider });
+ await withHarness(user.id, ['geo:read'], async (harness) => {
+ const routes = parseToolResult(
+ await harness.client.callTool({
+ name: 'search_transit_routes',
+ arguments: { from, to },
+ }),
+ ) as { provider: string; itineraries: unknown[]; dropped: number };
+ expect(routes.provider).toBe(provider);
+ expect(routes.itineraries).toHaveLength(1);
+ expect(routes.dropped).toBe(0);
+ });
+ });
it('registers search tools for geo scope and create tool for reservations scope', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, ['geo:read'], async (harness) => {
@@ -266,6 +281,66 @@ describe('MCP transit tools', () => {
}
});
+ it.each(['google', 'transitous'] as const)(
+ 'saves the %s fallback source from search rather than the configured primary',
+ async (provider) => {
+ const { user } = createUser(testDb);
+ const trip = createTrip(testDb, user.id, { start_date: '2026-12-03', end_date: '2026-12-04' });
+ const day = await dayOf(trip.id, '2026-12-03');
+ const primary = provider === 'google' ? 'transitous' : 'google';
+ const active = vi.spyOn(TransitService.prototype, 'activeProvider').mockResolvedValue(primary);
+ planMock.mockResolvedValue({ provider, itineraries: [itinerary] });
+ try {
+ await withHarness(user.id, ['geo:read', 'reservations:write'], async (harness) => {
+ const routes = parseToolResult(
+ await harness.client.callTool({
+ name: 'search_transit_routes',
+ arguments: { from, to },
+ }),
+ ) as { provider: typeof provider; itineraries: (typeof itinerary)[] };
+ const result = parseToolResult(
+ await harness.client.callTool({
+ name: 'create_transit_journey',
+ arguments: {
+ tripId: trip.id,
+ dayId: day.id,
+ from,
+ to,
+ itinerary: routes.itineraries[0],
+ provider: routes.provider,
+ },
+ }),
+ ) as { reservation: { metadata: string } };
+ expect(JSON.parse(result.reservation.metadata).transit.provider).toBe(provider);
+ });
+ expect(active).not.toHaveBeenCalled();
+ } finally {
+ active.mockRestore();
+ }
+ },
+ );
+
+ it('rejects an unknown itinerary provider before creating a journey', async () => {
+ const { user } = createUser(testDb);
+ const trip = createTrip(testDb, user.id, { start_date: '2026-12-03', end_date: '2026-12-04' });
+ const day = await dayOf(trip.id, '2026-12-03');
+ await withHarness(user.id, ['reservations:write'], async (harness) => {
+ const result = await harness.client.callTool({
+ name: 'create_transit_journey',
+ arguments: {
+ tripId: trip.id,
+ dayId: day.id,
+ from,
+ to,
+ itinerary,
+ provider: 'unknown',
+ },
+ });
+ expect(result.isError).toBe(true);
+ expect(await transitCount()).toBe(0);
+ });
+ });
+
it('rejects dateless, mismatched, and out-of-range journey dates', async () => {
const { user } = createUser(testDb);
const datelessTrip = createTrip(testDb, user.id);
diff --git a/server/tests/unit/nest/google-transit.provider.test.ts b/server/tests/unit/nest/google-transit.provider.test.ts
index 3d605d4a5..f084bea81 100644
--- a/server/tests/unit/nest/google-transit.provider.test.ts
+++ b/server/tests/unit/nest/google-transit.provider.test.ts
@@ -44,14 +44,16 @@ const GOOGLE_SETTINGS = { transit_provider: 'google', maps_api_key: 'test-key' }
* `GoogleTransitProvider` no longer takes a `DatabaseService` at all. The
* settings object already carries exactly the instance-tier value every test
* here configures, so `appSettingsStub.getValue` wraps it directly; the
- * per-user fallback tier was never exercised by this file (none of these
- * cases seed a per-user row), so it always answers null.
+ * optional personal key exercises the last tier without reading another user.
*/
-function makeProvider(settings: Record): GoogleTransitProvider {
+function makeProvider(
+ settings: Record,
+ personalKey: string | null = null,
+): GoogleTransitProvider {
const appSettingsStub = {
getValue: async (key: string) => settings[key] ?? null,
} as unknown as AppSettingsRepository;
- const usersStub = { getApiKeyColumn: async () => null } as unknown as UsersRepository;
+ const usersStub = { getApiKeyColumn: async () => personalKey } as unknown as UsersRepository;
return new GoogleTransitProvider(appSettingsStub, usersStub, noGoogleQuota);
}
@@ -180,6 +182,26 @@ function subwayRoute(): FixtureRoutes {
}
describe('activation', () => {
+ it('allows an instance key for fallback without selecting Google', async () => {
+ expect(await makeProvider({ maps_api_key: 'test-key' }).isAvailableForFallback()).toBe(true);
+ expect(await makeProvider({}).isAvailableForFallback()).toBe(false);
+ });
+
+ it('refuses personal-only fallback but preserves personal keys for Google primary', async () => {
+ const provider = makeProvider({ transit_provider: 'google' }, 'personal-key');
+ expect(await provider.isAvailableForFallback()).toBe(false);
+ expect(await provider.isActive(1)).toBe(true);
+ });
+
+ it('checks quota before making Google available for fallback', async () => {
+ const provider = makeProvider({ maps_api_key: 'test-key' });
+ const exhausted = vi.spyOn(noGoogleQuota, 'exhausted').mockResolvedValue(true);
+ try {
+ expect(await provider.isAvailableForFallback()).toBe(false);
+ } finally {
+ exhausted.mockRestore();
+ }
+ });
it('GTRANSIT-001: stays off while the instance is on Transitous, even with a key', async () => {
const provider = makeProvider({ maps_api_key: 'test-key' });
expect(await provider.isActive(1)).toBe(false);
diff --git a/server/tests/unit/nest/transit.service.test.ts b/server/tests/unit/nest/transit.service.test.ts
index f2abdc637..133b171f8 100644
--- a/server/tests/unit/nest/transit.service.test.ts
+++ b/server/tests/unit/nest/transit.service.test.ts
@@ -16,6 +16,7 @@ import { GoogleTransitProvider } from '../../../src/nest/transit/google-transit.
import { deriveTransitStats, type TransitLeg } from '../../../src/nest/transit/transit.helpers';
import { TransitService } from '../../../src/nest/transit/transit.service';
import { noGoogleQuota } from '../../helpers/google-quota';
+import { Test } from '@nestjs/testing';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
@@ -290,3 +291,103 @@ describe('upstream base URL', () => {
expect(String(fetchMock.mock.calls[0][0])).toMatch(/^https:\/\/api\.transitous\.org\/api\/v1\/geocode\?/);
});
});
+
+describe('empty route fallback', () => {
+ let queryNo = 0;
+ const route = { startTime: '2026-07-13T08:00:00Z', endTime: '2026-07-13T08:30:00Z', legs: [] };
+
+ describe.each(['google', 'transitous'] as const)('%s primary', (primary) => {
+ it.each(['primary-success', 'primary-error', 'empty', 'secondary-error', 'unavailable'])('%s', async (outcome) => {
+ const googlePrimary = primary === 'google';
+ const google = {
+ isActive: vi.fn().mockResolvedValue(googlePrimary),
+ isAvailableForFallback: vi.fn().mockResolvedValue(true),
+ plan: vi.fn(),
+ };
+ const module = await Test.createTestingModule({
+ providers: [
+ TransitService,
+ { provide: GoogleTransitProvider, useValue: google },
+ { provide: transitConfig.KEY, useValue: transitConfig() },
+ ],
+ }).compile();
+ try {
+ const service = module.get(TransitService);
+ google.plan.mockReset();
+ fetchMock.mockReset();
+ google.isAvailableForFallback.mockClear().mockResolvedValue(outcome !== 'unavailable');
+ const q = { from: `50.${queryNo++},13`, to: '52,14' };
+ const error = new Error('provider unavailable');
+ const primaryData = { itineraries: outcome === 'primary-success' ? [route] : [] };
+ if (googlePrimary) {
+ if (outcome === 'primary-error') google.plan.mockRejectedValue(error);
+ else google.plan.mockResolvedValue(primaryData);
+ if (outcome === 'secondary-error') fetchMock.mockRejectedValue(error);
+ else fetchMock.mockResolvedValue(okJson({ itineraries: [] }));
+ } else {
+ if (outcome === 'primary-error') fetchMock.mockRejectedValue(error);
+ else fetchMock.mockResolvedValue(okJson(primaryData));
+ if (outcome === 'secondary-error') google.plan.mockRejectedValue(error);
+ else google.plan.mockResolvedValue({ itineraries: [] });
+ }
+ if (outcome === 'primary-error') {
+ await expect(service.plan(q)).rejects.toBe(error);
+ } else {
+ const result = await service.plan(q);
+ expect(result.provider).toBe(primary);
+ expect(result.itineraries).toHaveLength(outcome === 'primary-success' ? 1 : 0);
+ }
+ if (outcome.startsWith('primary-')) {
+ expect(googlePrimary ? fetchMock : google.plan).not.toHaveBeenCalled();
+ }
+ if (!googlePrimary && outcome === 'unavailable') expect(google.plan).not.toHaveBeenCalled();
+ } finally {
+ await module.close();
+ }
+ });
+ });
+
+ it.each(['google', 'transitous'] as const)('falls back from %s and preserves request options', async (primary) => {
+ const google = {
+ isActive: vi.fn().mockResolvedValue(primary === 'google'),
+ isAvailableForFallback: vi.fn().mockResolvedValue(true),
+ plan: vi.fn().mockResolvedValue({ itineraries: primary === 'google' ? [] : [route] }),
+ };
+ const module = await Test.createTestingModule({
+ providers: [
+ TransitService,
+ { provide: GoogleTransitProvider, useValue: google },
+ { provide: transitConfig.KEY, useValue: transitConfig() },
+ ],
+ }).compile();
+ try {
+ fetchMock.mockResolvedValue(okJson({ itineraries: primary === 'google' ? [route] : [] }));
+ const q = {
+ from: `51.${queryNo++},13`,
+ to: '52,14',
+ time: '2026-07-13T08:00:00Z',
+ arriveBy: true,
+ modes: 'BUS',
+ maxTransfers: 2,
+ };
+ const service = module.get(TransitService);
+ const result = await service.plan(q, 'de', 42);
+ expect(result.provider).toBe(primary === 'google' ? 'transitous' : 'google');
+ expect(result.itineraries).toHaveLength(1);
+ expect(google.plan).toHaveBeenCalledWith(q, 'de', primary === 'google' ? 42 : 0);
+ const params = new URL(String(fetchMock.mock.calls[0][0])).searchParams;
+ expect(Object.fromEntries(params)).toMatchObject({
+ fromPlace: q.from,
+ toPlace: q.to,
+ arriveBy: 'true',
+ transitModes: 'BUS',
+ maxTransfers: '2',
+ time: '2026-07-13T08:00:00.000Z',
+ });
+ await service.plan(q, 'de', 42);
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ } finally {
+ await module.close();
+ }
+ });
+});
diff --git a/wiki/MCP-Tools-and-Resources.md b/wiki/MCP-Tools-and-Resources.md
index 187795a02..18a0af192 100644
--- a/wiki/MCP-Tools-and-Resources.md
+++ b/wiki/MCP-Tools-and-Resources.md
@@ -134,7 +134,7 @@ Transit search is powered by Transitous and uses the existing `geo:read` and `re
|---|---|---|
| `search_transit_stops` | `geo:read` | Search real public-transit stops and stations, optionally biased around coordinates. |
| `search_transit_routes` | `geo:read` | Search scheduled routes between two coordinates with time, mode, and transfer filters. Also returns `provider` (`transitous` or `google`, the backend that answered) and `dropped`, the number of provider itineraries that failed validation and are absent from the results. |
-| `create_transit_journey` | `reservations:write` | Save a selected route as a first-class automated transit journey on a trip day, recording the backend that plans routes for the caller as its provider. |
+| `create_transit_journey` | `reservations:write` | Save a selected route as a first-class automated transit journey on a trip day. Pass `provider` from `search_transit_routes` to preserve the actual source, including fallback results. Older callers that omit it retain the configured-provider behavior. |
### Reservations