diff --git a/apps/admin-dashboard/src/components/source-config-panel.tsx b/apps/admin-dashboard/src/components/source-config-panel.tsx index 2ef9d40..ba7ab3f 100644 --- a/apps/admin-dashboard/src/components/source-config-panel.tsx +++ b/apps/admin-dashboard/src/components/source-config-panel.tsx @@ -66,11 +66,17 @@ export function SourceConfigPanel({ source, onUpdate }: SourceConfigPanelProps) return creds } + function hasUserConfigFields(): boolean { + return Object.values(source.fields).some((field) => !isCredentialField(field)) + } + function buildReplaceBody(enabledValue: boolean): Parameters[1] { const body: Parameters[1] = { enabled: enabledValue } - if (Object.keys(source.fields).length > 0) { + + if (hasUserConfigFields()) { body.config = getUserConfig() } + return body } diff --git a/apps/admin-dashboard/src/lib/api.ts b/apps/admin-dashboard/src/lib/api.ts index dee2a5d..4b439fa 100644 --- a/apps/admin-dashboard/src/lib/api.ts +++ b/apps/admin-dashboard/src/lib/api.ts @@ -157,6 +157,12 @@ const sourceDefinitions: SourceDefinition[] = [ description: "Exa web search action. Requires EXA_API_KEY on the backend.", fields: {}, }, + { + id: "freya.google-maps", + name: "Google Maps", + description: "Google Maps Grounding Lite MCP tools for places, weather, routes, and Place IDs.", + fields: {}, + }, ] export function fetchSources(): Promise { diff --git a/apps/admin-dashboard/src/routes/_dashboard.tsx b/apps/admin-dashboard/src/routes/_dashboard.tsx index 91ff173..407e888 100644 --- a/apps/admin-dashboard/src/routes/_dashboard.tsx +++ b/apps/admin-dashboard/src/routes/_dashboard.tsx @@ -15,6 +15,7 @@ import { Loader2, TrainFront, LogOut, + Map as MapIcon, MapPin, Rss, Server, @@ -49,6 +50,7 @@ const SOURCE_ICONS: Record> "freya.weather": CloudSun, "freya.caldav": CalendarDays, "freya.google-calendar": Calendar, + "freya.google-maps": MapIcon, "freya.tfl": TrainFront, } diff --git a/apps/freya-backend/package.json b/apps/freya-backend/package.json index fc5f52c..bb60987 100644 --- a/apps/freya-backend/package.json +++ b/apps/freya-backend/package.json @@ -18,6 +18,7 @@ "@freya/core": "workspace:*", "@freya/source-caldav": "workspace:*", "@freya/source-google-calendar": "workspace:*", + "@freya/source-google-maps": "workspace:*", "@freya/source-location": "workspace:*", "@freya/source-tfl": "workspace:*", "@freya/source-weatherkit": "workspace:*", diff --git a/apps/freya-backend/src/google-maps/provider.test.ts b/apps/freya-backend/src/google-maps/provider.test.ts new file mode 100644 index 0000000..97f5b51 --- /dev/null +++ b/apps/freya-backend/src/google-maps/provider.test.ts @@ -0,0 +1,55 @@ +import type { GoogleMapsSourceOptions } from "@freya/source-google-maps" + +import { describe, expect, test } from "bun:test" + +import { GoogleMapsSourceProvider } from "./provider.ts" + +type McpClient = NonNullable + +class MockMcpClient implements McpClient { + async listTools(): ReturnType { + return { tools: [] } + } + + async readResource( + _params: Parameters[0], + ): ReturnType { + throw new Error("unexpected resource read") + } + + async callTool(_params: Parameters[0]): ReturnType { + return { structuredContent: {} } + } +} + +describe("GoogleMapsSourceProvider", () => { + test("sourceId is freya.google-maps", () => { + const provider = new GoogleMapsSourceProvider({ apiKey: "key" }) + expect(provider.sourceId).toBe("freya.google-maps") + }) + + test("throws when service API key is empty", () => { + expect(() => new GoogleMapsSourceProvider({ apiKey: "" })).toThrow( + "Google Maps MCP API key must be configured", + ) + }) + + test("returns source with service API key", async () => { + const provider = new GoogleMapsSourceProvider({ apiKey: "key" }) + + const source = await provider.feedSourceForUser("user-1", {}, null) + + expect(source.id).toBe("freya.google-maps") + }) + + test("allows injected test client with service API key", async () => { + const provider = new GoogleMapsSourceProvider({ + apiKey: "key", + client: new MockMcpClient(), + }) + + const source = await provider.feedSourceForUser("user-1", {}, null) + + expect(source.id).toBe("freya.google-maps") + }) +}) diff --git a/apps/freya-backend/src/google-maps/provider.ts b/apps/freya-backend/src/google-maps/provider.ts new file mode 100644 index 0000000..535922f --- /dev/null +++ b/apps/freya-backend/src/google-maps/provider.ts @@ -0,0 +1,39 @@ +import { GoogleMapsSource, type GoogleMapsSourceOptions } from "@freya/source-google-maps" + +import type { FeedSourceProvider } from "../session/feed-source-provider.ts" + +export interface GoogleMapsSourceProviderOptions { + readonly apiKey: string + readonly client?: GoogleMapsSourceOptions["client"] +} + +export class GoogleMapsSourceProvider implements FeedSourceProvider { + readonly sourceId = "freya.google-maps" + + private readonly apiKey: string + private readonly client: GoogleMapsSourceProviderOptions["client"] + + constructor(options: GoogleMapsSourceProviderOptions) { + if (!nonEmptyString(options.apiKey)) { + throw new Error("Google Maps MCP API key must be configured") + } + + this.apiKey = options.apiKey + this.client = options.client + } + + async feedSourceForUser( + _userId: string, + _config: unknown, + _credentials: unknown, + ): Promise { + return new GoogleMapsSource({ + apiKey: this.apiKey, + client: this.client, + }) + } +} + +function nonEmptyString(value: string): boolean { + return typeof value === "string" && value.trim().length > 0 +} diff --git a/apps/freya-backend/src/server.ts b/apps/freya-backend/src/server.ts index 0340ff3..d2ab5e2 100644 --- a/apps/freya-backend/src/server.ts +++ b/apps/freya-backend/src/server.ts @@ -11,6 +11,7 @@ import { createDatabase } from "./db/index.ts" import { registerFeedHttpHandlers } from "./engine/http.ts" import { createFeedEnhancer } from "./enhancement/enhance-feed.ts" import { createLlmClient } from "./enhancement/llm-client.ts" +import { GoogleMapsSourceProvider } from "./google-maps/provider.ts" import { CredentialEncryptor } from "./lib/crypto.ts" import { registerLocationHttpHandlers } from "./location/http.ts" import { LocationSourceProvider } from "./location/provider.ts" @@ -47,6 +48,11 @@ function main() { ) } + const googleMapsApiKey = process.env.GOOGLE_MAPS_API_KEY ?? process.env.GOOGLE_MAPS_MCP_API_KEY + if (!googleMapsApiKey) { + throw new Error("GOOGLE_MAPS_API_KEY or GOOGLE_MAPS_MCP_API_KEY must be set") + } + const sessionManager = new UserSessionManager({ db, providers: [ @@ -62,6 +68,9 @@ function main() { }), new TflSourceProvider({ apiKey: process.env.TFL_API_KEY! }), new WebSearchSourceProvider({ apiKey: process.env.EXA_API_KEY }), + new GoogleMapsSourceProvider({ + apiKey: googleMapsApiKey, + }), ], feedEnhancer, credentialEncryptor, diff --git a/bun.lock b/bun.lock index e07a4ca..3e75fc1 100644 --- a/bun.lock +++ b/bun.lock @@ -53,6 +53,7 @@ "@freya/core": "workspace:*", "@freya/source-caldav": "workspace:*", "@freya/source-google-calendar": "workspace:*", + "@freya/source-google-maps": "workspace:*", "@freya/source-location": "workspace:*", "@freya/source-tfl": "workspace:*", "@freya/source-weatherkit": "workspace:*", @@ -193,6 +194,14 @@ "arktype": "^2.1.0", }, }, + "packages/freya-source-google-maps": { + "name": "@freya/source-google-maps", + "version": "0.0.0", + "dependencies": { + "@freya/source-mcp": "workspace:*", + "arktype": "^2.1.0", + }, + }, "packages/freya-source-location": { "name": "@freya/source-location", "version": "0.0.0", @@ -672,6 +681,8 @@ "@freya/source-google-calendar": ["@freya/source-google-calendar@workspace:packages/freya-source-google-calendar"], + "@freya/source-google-maps": ["@freya/source-google-maps@workspace:packages/freya-source-google-maps"], + "@freya/source-location": ["@freya/source-location@workspace:packages/freya-source-location"], "@freya/source-tfl": ["@freya/source-tfl@workspace:packages/freya-source-tfl"], diff --git a/packages/freya-source-google-maps/package.json b/packages/freya-source-google-maps/package.json new file mode 100644 index 0000000..0a4c63a --- /dev/null +++ b/packages/freya-source-google-maps/package.json @@ -0,0 +1,14 @@ +{ + "name": "@freya/source-google-maps", + "version": "0.0.0", + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "scripts": { + "test": "bun test src/" + }, + "dependencies": { + "@freya/source-mcp": "workspace:*", + "arktype": "^2.1.0" + } +} diff --git a/packages/freya-source-google-maps/src/google-maps-source.test.ts b/packages/freya-source-google-maps/src/google-maps-source.test.ts new file mode 100644 index 0000000..8a9cbed --- /dev/null +++ b/packages/freya-source-google-maps/src/google-maps-source.test.ts @@ -0,0 +1,135 @@ +import type { + McpCallToolParams, + McpCallToolResult, + McpClient, + McpListToolsResult, + McpReadResourceParams, + McpReadResourceResult, +} from "@freya/source-mcp" + +import { describe, expect, test } from "bun:test" + +import { GoogleMapsAction, GoogleMapsSource, GoogleMapsSourceId, GoogleMapsTool } from "./index" + +class MockMcpClient implements McpClient { + readonly calls: McpCallToolParams[] = [] + + async listTools(): Promise { + return { + tools: Object.values(GoogleMapsTool).map((name) => ({ + name, + description: `${name} description`, + })), + } + } + + async readResource(_params: McpReadResourceParams): Promise { + throw new Error("unexpected resource read") + } + + async callTool(params: McpCallToolParams): Promise { + this.calls.push(params) + return { + structuredContent: { + tool: params.name, + arguments: params.arguments ?? {}, + }, + } + } +} + +describe("GoogleMapsSource", () => { + test("uses the Google Maps source id", () => { + const source = new GoogleMapsSource({ client: new MockMcpClient() }) + expect(source.id).toBe(GoogleMapsSourceId) + }) + + test("exposes documented Google Maps MCP tools as actions", async () => { + const source = new GoogleMapsSource({ client: new MockMcpClient() }) + + const actions = await source.listActions() + + expect(Object.keys(actions).sort()).toEqual(Object.values(GoogleMapsAction).sort()) + expect(actions[GoogleMapsAction.SearchPlaces]!.id).toBe(GoogleMapsAction.SearchPlaces) + }) + + test("maps action execution to the underlying MCP tool", async () => { + const client = new MockMcpClient() + const source = new GoogleMapsSource({ client }) + + const result = await source.executeAction(GoogleMapsAction.SearchPlaces, { + textQuery: "coffee shops near Golden Gate Park", + regionCode: "US", + }) + + expect(client.calls).toEqual([ + { + name: GoogleMapsTool.SearchPlaces, + arguments: { + textQuery: "coffee shops near Golden Gate Park", + regionCode: "US", + }, + }, + ]) + expect(result).toEqual({ + tool: GoogleMapsTool.SearchPlaces, + arguments: { + textQuery: "coffee shops near Golden Gate Park", + regionCode: "US", + }, + }) + }) + + test("validates action input before calling the MCP tool", async () => { + const client = new MockMcpClient() + const source = new GoogleMapsSource({ client }) + + await expectRejectsWithMessage( + source.executeAction(GoogleMapsAction.SearchPlaces, {}), + "textQuery must be a string", + ) + expect(client.calls).toEqual([]) + }) + + test("validates resolve names query objects", async () => { + const client = new MockMcpClient() + const source = new GoogleMapsSource({ client }) + + await expectRejectsWithMessage( + source.executeAction(GoogleMapsAction.ResolveNames, { queries: [{}] }), + "queries[0].text must be a string", + ) + expect(client.calls).toEqual([]) + }) + + test("does not produce feed items or context by default", async () => { + const source = new GoogleMapsSource({ client: new MockMcpClient() }) + + const contextEntries = await source.fetchContext(undefined as never) + const items = await source.fetchItems(undefined as never) + + expect(contextEntries).toBeNull() + expect(items).toEqual([]) + }) +}) + +async function expectRejectsWithMessage( + promise: Promise, + expectedMessage: string, +): Promise { + try { + await promise + } catch (err) { + expect(errorMessage(err)).toContain(expectedMessage) + return + } + + throw new Error(`Expected promise to reject with "${expectedMessage}"`) +} + +function errorMessage(err: unknown): string { + if (err instanceof Error) { + return err.message + } + return String(err) +} diff --git a/packages/freya-source-google-maps/src/google-maps-source.ts b/packages/freya-source-google-maps/src/google-maps-source.ts new file mode 100644 index 0000000..a432a02 --- /dev/null +++ b/packages/freya-source-google-maps/src/google-maps-source.ts @@ -0,0 +1,151 @@ +import { + McpSource, + type McpActionMapping, + type McpClient, + type McpHttpHeaders, + type McpSourceOptions, +} from "@freya/source-mcp" + +import { + ComputeRoutesInput, + LookupWeatherInput, + ResolveMapsUrlsInput, + ResolveNamesInput, + SearchPlacesInput, +} from "./schemas" + +export type GoogleMapsApiKey = string | (() => Promise) + +export interface GoogleMapsSourceOptions { + readonly endpoint?: string | URL + readonly apiKey?: GoogleMapsApiKey + readonly timeoutMs?: number + readonly headers?: McpHttpHeaders | (() => Promise) + readonly requestInit?: RequestInit + readonly transportOptions?: McpSourceOptions["transportOptions"] + readonly client?: McpClient + readonly clientFactory?: McpSourceOptions["clientFactory"] +} + +export const GoogleMapsSourceId = "freya.google-maps" + +export const GoogleMapsMcpEndpoint = "https://mapstools.googleapis.com/mcp" + +export const GoogleMapsAction = { + SearchPlaces: "search-places", + LookupWeather: "lookup-weather", + ComputeRoutes: "compute-routes", + ResolveNames: "resolve-names", + ResolveMapsUrls: "resolve-maps-urls", +} as const + +export type GoogleMapsAction = (typeof GoogleMapsAction)[keyof typeof GoogleMapsAction] + +export const GoogleMapsTool = { + SearchPlaces: "search_places", + LookupWeather: "lookup_weather", + ComputeRoutes: "compute_routes", + ResolveNames: "resolve_names", + ResolveMapsUrls: "resolve_maps_urls", +} as const + +export type GoogleMapsTool = (typeof GoogleMapsTool)[keyof typeof GoogleMapsTool] + +const GoogleMapsActions = { + [GoogleMapsAction.SearchPlaces]: { + tool: GoogleMapsTool.SearchPlaces, + description: + "Find places, businesses, addresses, locations, and points of interest with Google Maps.", + input: SearchPlacesInput, + }, + [GoogleMapsAction.LookupWeather]: { + tool: GoogleMapsTool.LookupWeather, + description: "Retrieve current conditions and weather forecasts through Google Maps.", + input: LookupWeatherInput, + }, + [GoogleMapsAction.ComputeRoutes]: { + tool: GoogleMapsTool.ComputeRoutes, + description: "Compute a Google Maps route between an origin and destination.", + input: ComputeRoutesInput, + }, + [GoogleMapsAction.ResolveNames]: { + tool: GoogleMapsTool.ResolveNames, + description: "Resolve specific place names or addresses into Google Maps Place IDs.", + input: ResolveNamesInput, + }, + [GoogleMapsAction.ResolveMapsUrls]: { + tool: GoogleMapsTool.ResolveMapsUrls, + description: "Resolve Google Maps URLs into canonical Google Maps Place IDs.", + input: ResolveMapsUrlsInput, + }, +} as const satisfies Record + +export class GoogleMapsSource extends McpSource { + constructor(options: GoogleMapsSourceOptions = {}) { + super({ + id: GoogleMapsSourceId, + url: options.endpoint ?? GoogleMapsMcpEndpoint, + clientName: "freya-source-google-maps", + clientVersion: "0.0.0", + timeoutMs: options.timeoutMs, + headers: createGoogleMapsHeaders({ + headers: options.headers, + apiKey: options.apiKey, + }), + requestInit: options.requestInit, + transportOptions: options.transportOptions, + client: options.client, + clientFactory: options.clientFactory, + actions: GoogleMapsActions, + }) + } +} + +interface GoogleMapsHeaderOptions { + readonly headers: McpHttpHeaders | (() => Promise) | undefined + readonly apiKey: GoogleMapsApiKey | undefined +} + +function createGoogleMapsHeaders({ + headers, + apiKey, +}: GoogleMapsHeaderOptions): McpHttpHeaders | (() => Promise) | undefined { + if (!apiKey) { + return headers + } + + return async () => { + const merged = new Headers() + const resolvedHeaders = typeof headers === "function" ? await headers() : headers + if (resolvedHeaders) { + applyHeaders(merged, resolvedHeaders) + } + + if (apiKey) { + const resolvedApiKey = typeof apiKey === "function" ? await apiKey() : apiKey + merged.set("x-goog-api-key", resolvedApiKey) + } + + return merged + } +} + +function applyHeaders(target: Headers, headers: McpHttpHeaders): void { + if (headers instanceof Headers) { + headers.forEach((value, key) => { + target.set(key, value) + }) + return + } + + if (Array.isArray(headers)) { + for (const [key, value] of headers) { + target.set(key, value) + } + return + } + + for (const [key, value] of Object.entries(headers)) { + target.set(key, value) + } +} diff --git a/packages/freya-source-google-maps/src/index.ts b/packages/freya-source-google-maps/src/index.ts new file mode 100644 index 0000000..eda8c0d --- /dev/null +++ b/packages/freya-source-google-maps/src/index.ts @@ -0,0 +1,17 @@ +export { + GoogleMapsAction, + GoogleMapsMcpEndpoint, + GoogleMapsSource, + GoogleMapsSourceId, + GoogleMapsTool, + type GoogleMapsApiKey, + type GoogleMapsSourceOptions, +} from "./google-maps-source" + +export { + ComputeRoutesInput, + LookupWeatherInput, + ResolveMapsUrlsInput, + ResolveNamesInput, + SearchPlacesInput, +} from "./schemas" diff --git a/packages/freya-source-google-maps/src/schemas.ts b/packages/freya-source-google-maps/src/schemas.ts new file mode 100644 index 0000000..023f685 --- /dev/null +++ b/packages/freya-source-google-maps/src/schemas.ts @@ -0,0 +1,41 @@ +import { type } from "arktype" + +const ResolveNameQuery = type({ + "+": "reject", + text: "string", +}) + +export const SearchPlacesInput = type({ + "+": "reject", + textQuery: "string", + "locationBias?": "unknown", + "languageCode?": "string", + "regionCode?": "string", +}) + +export const LookupWeatherInput = type({ + "+": "reject", + location: "unknown", + "date?": "unknown", + "hour?": "number", + "unitsSystem?": "'UNITS_SYSTEM_UNSPECIFIED' | 'METRIC' | 'IMPERIAL'", +}) + +export const ComputeRoutesInput = type({ + "+": "reject", + origin: "unknown", + destination: "unknown", + "travelMode?": "'ROUTE_TRAVEL_MODE_UNSPECIFIED' | 'DRIVE' | 'WALK'", +}) + +export const ResolveNamesInput = type({ + "+": "reject", + queries: ResolveNameQuery.array(), + "locationBias?": "unknown", + "regionCode?": "string", +}) + +export const ResolveMapsUrlsInput = type({ + "+": "reject", + urls: "string[]", +}) diff --git a/packages/freya-source-google-maps/tsconfig.json b/packages/freya-source-google-maps/tsconfig.json new file mode 100644 index 0000000..0c91d62 --- /dev/null +++ b/packages/freya-source-google-maps/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src"] +}