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
1,162 changes: 605 additions & 557 deletions package-lock.json

Large diffs are not rendered by default.

16 changes: 8 additions & 8 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,9 @@
"inspect:mcp:cli": "mcp-inspector --cli node --use-env-proxy dist/index.js --"
},
"dependencies": {
"@ignfab/gpf-schema-store": "^0.2.2",
"@modelcontextprotocol/sdk": "^1.31.0",
"@rgrove/parse-xml": "^5.0.0",
"@ignfab/gpf-schema-store": "^0.3.0",
"@modelcontextprotocol/sdk": "^1.32.2",
"@rgrove/parse-xml": "^5.1.0",
"@turf/bbox": "^7.4.0",
"@turf/bbox-polygon": "^7.4.0",
"@turf/centroid": "^7.4.0",
Expand All @@ -73,17 +73,17 @@
"zod": "^3.25.76"
},
"devDependencies": {
"@modelcontextprotocol/inspector": "^2.9.0",
"@modelcontextprotocol/inspector": "^2.13.0",
"@turf/intersect": "^7.4.0",
"@types/geojson": "^7946.0.16",
"@types/node": "^26.6.3",
"@types/node": "^26.6.6",
"@types/supertest": "^7.2.1",
"@vitest/coverage-v8": "^5.0.3",
"@vitest/coverage-v8": "^5.1.0",
"ajv": "^8.20.0",
"ajv-formats": "^3.0.1",
"supertest": "^7.3.0",
"supertest": "^7.3.1",
"typescript": "^7.0.2",
"vitest": "^5.0.3"
"vitest": "^5.1.0"
},
"allowScripts": {
"@modelcontextprotocol/inspector": false
Expand Down
5 changes: 3 additions & 2 deletions src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,8 @@ const logFormats = ["json", "simple"] as const;
const nodeEnvs = ["development", "test", "production"] as const;

// Shared keys used by both `fields` and `boost` in MiniSearchCollectionSearchOptions.
export const MINISEARCH_INDEXED_OPTION_KEYS = [
// TODO: export INDEXED_SEARCH_FIELDS in gpf-schema-store instead and reuse it here
const MINISEARCH_INDEXED_OPTION_KEYS = [
'namespace',
'name',
'identifierTokens',
Expand All @@ -119,7 +120,7 @@ export const MINISEARCH_INDEXED_OPTION_KEYS = [
'selectionCriteria',
] as const;

export const MINISEARCH_COMBINE_WITH_VALUES = ["AND", "OR"] as const;
const MINISEARCH_COMBINE_WITH_VALUES = ["AND", "OR"] as const;

export const miniSearchOptionsSchema = z.object({
fields: z.array(z.enum(MINISEARCH_INDEXED_OPTION_KEYS)).optional(),
Expand Down
5 changes: 2 additions & 3 deletions src/tools/GpfSearchTypesTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import { z } from "zod";

import { READ_ONLY_CLOSED_WORLD_TOOL_ANNOTATIONS } from "../helpers/toolAnnotations.js";
import { wfsSchemaStore } from "../wfs/catalog.js";
import type { DetailedCollectionSearchMatch } from "../wfs/catalog.js";
import logger from "../logger.js";

// --- Schemas ---
Expand Down Expand Up @@ -91,8 +90,8 @@ class GpfSearchTypesTool extends BaseTool<GpfSearchTypesInput> {
});

const maxResults = input.max_results || 10;
const featureTypes = await wfsSchemaStore.searchFeatureTypesWithScores(input.query, maxResults);
const results = await Promise.all(featureTypes.map(async ({ id, score, queryTerms, terms, match }: DetailedCollectionSearchMatch) => {
const featureTypes = await wfsSchemaStore.searchFeatureTypes(input.query, maxResults);
const results = await Promise.all(featureTypes.map(async ({ id, score, queryTerms, terms, match }) => {
try {
const featureType = await wfsSchemaStore.getFeatureType(id);
return {
Expand Down
30 changes: 4 additions & 26 deletions src/wfs/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type {
MiniSearchCollectionSearchOptions,
} from '@ignfab/gpf-schema-store';
import { getCollectionCatalog } from '@ignfab/gpf-schema-store';
import { getEnv, MINISEARCH_INDEXED_OPTION_KEYS, miniSearchOptionsSchema } from '../config/env.js';
import { getEnv, miniSearchOptionsSchema } from '../config/env.js';

export type GpfFeatureType = {
typename: string;
Expand All @@ -18,20 +18,6 @@ export type GpfFeatureType = {

export const GPF_WFS_URL = "https://data.geopf.fr/wfs";

// --- Types ---

type MiniSearchOptions = MiniSearchCollectionSearchOptions;

/**
* CollectionSearchMatch extended with MiniSearch-specific metadata.
* Fields are populated when the underlying engine supports searchDetailed().
*/
export type DetailedCollectionSearchMatch = CollectionSearchMatch & {
queryTerms?: string[];
terms?: string[];
match?: Record<string, Array<typeof MINISEARCH_INDEXED_OPTION_KEYS[number]>>;
};

// --- Errors ---

export class FeatureTypeNotFoundError extends Error {
Expand All @@ -51,7 +37,7 @@ function invalidSearchOptionsError(reason: string): Error {

// Parses and validates a plain-object value into MiniSearchCollectionSearchOptions.
// Throws a descriptive error if the value has unexpected keys or wrong value types.
function parseMiniSearchOptions(value: unknown): MiniSearchOptions {
function parseMiniSearchOptions(value: unknown): MiniSearchCollectionSearchOptions {
const result = miniSearchOptionsSchema.safeParse(value);
if (!result.success) {
const issue = result.error.issues[0];
Expand All @@ -64,7 +50,7 @@ function parseMiniSearchOptions(value: unknown): MiniSearchOptions {

// Reads MiniSearch options from the GPF_WFS_MINISEARCH_OPTIONS environment variable.
// Returns undefined when the variable is absent or empty.
export function loadMiniSearchOptionsFromEnv(): MiniSearchOptions | undefined {
export function loadMiniSearchOptionsFromEnv(): MiniSearchCollectionSearchOptions | undefined {
const raw = getEnv().GPF_WFS_MINISEARCH_OPTIONS;
if (!raw) {
return undefined;
Expand All @@ -83,15 +69,7 @@ export class WfsSchemaStore {
this.catalog = getCollectionCatalog(options);
}

async searchFeatureTypesWithScores(query: string, maxResults: number = 20): Promise<DetailedCollectionSearchMatch[]> {
// Use searchDetailed() when available (MiniSearchCollectionSearchEngine) to surface
// queryTerms, terms and match in addition to id and score.
// The method is not part of the CollectionCatalog interface, so we access it at
// runtime through the engine stored on InMemoryCollectionCatalog.
const engine = (this.catalog as unknown as { searchEngine?: { searchDetailed?: (q: string, opts: object) => DetailedCollectionSearchMatch[] } }).searchEngine;
if (typeof engine?.searchDetailed === 'function') {
return engine.searchDetailed(query, { limit: maxResults });
}
async searchFeatureTypes(query: string, maxResults: number = 20): Promise<CollectionSearchMatch[]> {
return this.catalog.search(query, { limit: maxResults });
}

Expand Down
4 changes: 2 additions & 2 deletions test/tools/gpf-search-types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ describe("Test GpfSearchTypesTool", () => {
required: [],
};

vi.mocked(wfsSchemaStore).searchFeatureTypesWithScores.mockResolvedValue(mockSearchResults as never);
vi.mocked(wfsSchemaStore).searchFeatureTypes.mockResolvedValue(mockSearchResults as never);

// First call succeeds, second call throws an error
vi.mocked(wfsSchemaStore).getFeatureType.mockImplementation(async (id: string) => {
Expand Down Expand Up @@ -118,7 +118,7 @@ describe("Test GpfSearchTypesTool", () => {
required: [],
};

vi.mocked(wfsSchemaStore).searchFeatureTypesWithScores.mockResolvedValue(mockSearchResults as never);
vi.mocked(wfsSchemaStore).searchFeatureTypes.mockResolvedValue(mockSearchResults as never);
vi.mocked(wfsSchemaStore).getFeatureType.mockResolvedValue({
typename: "BDTOPO_V3:batiment",
schema: mockFeatureType,
Expand Down
12 changes: 6 additions & 6 deletions test/wfs/catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,16 +85,16 @@ describe("Test WfsSchemaStore",() => {
});
});

describe("searchFeatureTypesWithScores",() => {
describe("searchFeatureTypes",() => {
it("should find BDTOPO_V3:batiment for 'bâtiments bdtopo'", async () => {
const featureTypes = await wfsSchemaStore.searchFeatureTypesWithScores("bâtiments bdtopo");
const featureTypes = await wfsSchemaStore.searchFeatureTypes("bâtiments bdtopo");
expect(featureTypes).toBeDefined();
expect(featureTypes.length).toBeGreaterThan(0);
const featureTypeNames= featureTypes.map((featureType)=>featureType.id);
expect(featureTypeNames).toContain("BDTOPO_V3:batiment");
});
it("should find BDTOPO_V3:departement and ADMINEXPRESS-COG.LATEST:departement for 'départements'", async () => {
const featureTypes = await wfsSchemaStore.searchFeatureTypesWithScores("départements");
const featureTypes = await wfsSchemaStore.searchFeatureTypes("départements");
expect(featureTypes).toBeDefined();
expect(featureTypes.length).toBeGreaterThan(0);
const featureTypeNames= featureTypes.map((featureType)=>featureType.id);
Expand All @@ -111,14 +111,14 @@ describe("Test WfsSchemaStore",() => {
boost: { title: 4.0 },
}
});
const featureTypes = await tuned.searchFeatureTypesWithScores("bâtiments bdtopo");
const featureTypes = await tuned.searchFeatureTypes("bâtiments bdtopo");
expect(featureTypes).toBeDefined();
expect(featureTypes.length).toBeGreaterThan(0);
const featureTypeNames= featureTypes.map((featureType)=>featureType.id);
expect(featureTypeNames).toContain("BDTOPO_V3:batiment");
});
it("should return scored results for 'bâtiments bdtopo'", async () => {
const featureTypes = await wfsSchemaStore.searchFeatureTypesWithScores("bâtiments bdtopo");
const featureTypes = await wfsSchemaStore.searchFeatureTypes("bâtiments bdtopo");
expect(featureTypes).toBeDefined();
expect(featureTypes.length).toBeGreaterThan(0);

Expand All @@ -128,7 +128,7 @@ describe("Test WfsSchemaStore",() => {
});

it("should include MiniSearch detailed metadata", async () => {
const featureTypes = await wfsSchemaStore.searchFeatureTypesWithScores("bâtiments bdtopo");
const featureTypes = await wfsSchemaStore.searchFeatureTypes("bâtiments bdtopo");
const batimentResult = featureTypes.find((featureType) => featureType.id === "BDTOPO_V3:batiment");

expect(batimentResult).toBeDefined();
Expand Down
Loading