@voyantjs/catalog-mcp 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,14 @@
1
+ /**
2
+ * `get_entity` tool — fetch a single resolved CatalogEntry view by id.
3
+ * Wraps the per-vertical `resolveEntity` helper injected via context.
4
+ */
5
+ import { z } from "zod";
6
+ import type { McpToolDefinition, McpToolResult } from "../contract.js";
7
+ declare const getEntityArgs: z.ZodObject<{
8
+ vertical: z.ZodString;
9
+ entityId: z.ZodString;
10
+ }, z.core.$strip>;
11
+ export type GetEntityArgs = z.infer<typeof getEntityArgs>;
12
+ export declare const getEntityTool: McpToolDefinition<GetEntityArgs, McpToolResult>;
13
+ export {};
14
+ //# sourceMappingURL=get-entity.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"get-entity.d.ts","sourceRoot":"","sources":["../../src/tools/get-entity.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAEvB,OAAO,KAAK,EAAE,iBAAiB,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAA;AAGtE,QAAA,MAAM,aAAa;;;iBAKjB,CAAA;AAEF,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,aAAa,CAAC,CAAA;AAEzD,eAAO,MAAM,aAAa,EAAE,iBAAiB,CAAC,aAAa,EAAE,aAAa,CAoDzE,CAAA"}
@@ -0,0 +1,60 @@
1
+ /**
2
+ * `get_entity` tool — fetch a single resolved CatalogEntry view by id.
3
+ * Wraps the per-vertical `resolveEntity` helper injected via context.
4
+ */
5
+ import { z } from "zod";
6
+ import { requireService } from "../registry.js";
7
+ const getEntityArgs = z.object({
8
+ vertical: z
9
+ .string()
10
+ .describe('The catalog vertical (e.g. "products", "cruises", "hospitality").'),
11
+ entityId: z.string().describe("Entity id."),
12
+ });
13
+ export const getEntityTool = {
14
+ name: "get_entity",
15
+ description: "Fetch a single resolved CatalogEntry by vertical + id. Returns the visibility-filtered view " +
16
+ "for the calling actor (overlays applied, internal-only fields hidden when applicable).",
17
+ inputSchema: getEntityArgs,
18
+ async handler(args, context) {
19
+ const resolveEntity = requireService(context.catalog.resolveEntity, "resolveEntity");
20
+ const view = await resolveEntity(args.vertical, args.entityId, context.defaultScope);
21
+ if (!view) {
22
+ return {
23
+ isError: true,
24
+ content: [
25
+ {
26
+ type: "text",
27
+ text: `[NOT_FOUND] No ${args.vertical} entity with id "${args.entityId}".`,
28
+ },
29
+ ],
30
+ structuredContent: {
31
+ error: { code: "NOT_FOUND", vertical: args.vertical, entityId: args.entityId },
32
+ },
33
+ };
34
+ }
35
+ const title = view.fields.title ??
36
+ view.fields.name ??
37
+ view.entityId;
38
+ const description = view.fields.description ??
39
+ view.fields.shortDescription ??
40
+ "";
41
+ return {
42
+ content: [
43
+ {
44
+ type: "text",
45
+ text: [
46
+ `# ${title}`,
47
+ description ? `\n${description}` : "",
48
+ `\n_id: ${view.entityId} • vertical: ${view.vertical}_`,
49
+ ].join("\n"),
50
+ },
51
+ ],
52
+ structuredContent: {
53
+ vertical: view.vertical,
54
+ entityId: view.entityId,
55
+ fields: view.fields,
56
+ provenance: view.provenance,
57
+ },
58
+ };
59
+ },
60
+ };
@@ -0,0 +1,19 @@
1
+ /**
2
+ * `get_quote` tool — calls the source adapter to lock a priced quote.
3
+ * Live-pricing path; never cached, never embedded.
4
+ *
5
+ * The returned `quoteId` can be passed to a downstream booking flow. The
6
+ * `expiresAt` field tells agents when to warn users that the price may
7
+ * change.
8
+ */
9
+ import { z } from "zod";
10
+ import type { McpToolDefinition, McpToolResult } from "../contract.js";
11
+ declare const getQuoteArgs: z.ZodObject<{
12
+ vertical: z.ZodString;
13
+ entityId: z.ZodString;
14
+ parameters: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
15
+ }, z.core.$strip>;
16
+ export type GetQuoteArgs = z.infer<typeof getQuoteArgs>;
17
+ export declare const getQuoteTool: McpToolDefinition<GetQuoteArgs, McpToolResult>;
18
+ export {};
19
+ //# sourceMappingURL=get-quote.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"get-quote.d.ts","sourceRoot":"","sources":["../../src/tools/get-quote.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAEvB,OAAO,KAAK,EAAE,iBAAiB,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAA;AAGtE,QAAA,MAAM,YAAY;;;;iBAOhB,CAAA;AAEF,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,YAAY,CAAC,CAAA;AAEvD,eAAO,MAAM,YAAY,EAAE,iBAAiB,CAAC,YAAY,EAAE,aAAa,CA0BvE,CAAA"}
@@ -0,0 +1,42 @@
1
+ /**
2
+ * `get_quote` tool — calls the source adapter to lock a priced quote.
3
+ * Live-pricing path; never cached, never embedded.
4
+ *
5
+ * The returned `quoteId` can be passed to a downstream booking flow. The
6
+ * `expiresAt` field tells agents when to warn users that the price may
7
+ * change.
8
+ */
9
+ import { z } from "zod";
10
+ import { requireService } from "../registry.js";
11
+ const getQuoteArgs = z.object({
12
+ vertical: z.string().describe("The catalog vertical."),
13
+ entityId: z.string().describe("Entity id."),
14
+ parameters: z
15
+ .record(z.string(), z.unknown())
16
+ .default({})
17
+ .describe("Vertical-specific quote parameters: dates, pax, currency, fare class, etc."),
18
+ });
19
+ export const getQuoteTool = {
20
+ name: "get_quote",
21
+ description: "Lock a live priced quote for a catalog entry. Returns a quoteId that can be passed to a " +
22
+ "booking flow plus the locked total price and an optional expiry timestamp. " +
23
+ "Always live; never cached at the catalog plane.",
24
+ inputSchema: getQuoteArgs,
25
+ async handler(args, context) {
26
+ const getQuote = requireService(context.catalog.getQuote, "getQuote");
27
+ const result = await getQuote(args.vertical, args.entityId, args.parameters);
28
+ const expiry = result.expiresAt ? ` (expires ${result.expiresAt})` : "";
29
+ const summary = `Quote ${result.quoteId} for ${args.vertical}/${args.entityId}: ${result.totalPrice.amount} ${result.totalPrice.currency}${expiry}.`;
30
+ return {
31
+ content: [{ type: "text", text: summary }],
32
+ structuredContent: {
33
+ vertical: args.vertical,
34
+ entityId: args.entityId,
35
+ quoteId: result.quoteId,
36
+ totalPrice: result.totalPrice,
37
+ expiresAt: result.expiresAt,
38
+ breakdown: result.breakdown,
39
+ },
40
+ };
41
+ },
42
+ };
@@ -0,0 +1,32 @@
1
+ /**
2
+ * `search_catalog` tool — keyword / hybrid / semantic search across a
3
+ * vertical. Wraps `executeSemanticSearch` from `@voyantjs/catalog-rag`.
4
+ *
5
+ * Visibility filtering and audience-pool enforcement happen at the
6
+ * underlying API layer; the tool just wires args through.
7
+ */
8
+ import { z } from "zod";
9
+ import type { McpToolDefinition, McpToolResult } from "../contract.js";
10
+ declare const searchCatalogArgs: z.ZodObject<{
11
+ vertical: z.ZodString;
12
+ query: z.ZodString;
13
+ mode: z.ZodDefault<z.ZodEnum<{
14
+ keyword: "keyword";
15
+ semantic: "semantic";
16
+ hybrid: "hybrid";
17
+ }>>;
18
+ limit: z.ZodDefault<z.ZodNumber>;
19
+ filters: z.ZodOptional<z.ZodArray<z.ZodObject<{
20
+ field: z.ZodString;
21
+ op: z.ZodEnum<{
22
+ eq: "eq";
23
+ in: "in";
24
+ range: "range";
25
+ }>;
26
+ value: z.ZodUnknown;
27
+ }, z.core.$strip>>>;
28
+ }, z.core.$strip>;
29
+ export type SearchCatalogArgs = z.infer<typeof searchCatalogArgs>;
30
+ export declare const searchCatalogTool: McpToolDefinition<SearchCatalogArgs, McpToolResult>;
31
+ export {};
32
+ //# sourceMappingURL=search-catalog.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"search-catalog.d.ts","sourceRoot":"","sources":["../../src/tools/search-catalog.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAGH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAEvB,OAAO,KAAK,EAAE,iBAAiB,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAA;AAGtE,QAAA,MAAM,iBAAiB;;;;;;;;;;;;;;;;;;iBAkBrB,CAAA;AAEF,MAAM,MAAM,iBAAiB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,iBAAiB,CAAC,CAAA;AAEjE,eAAO,MAAM,iBAAiB,EAAE,iBAAiB,CAAC,iBAAiB,EAAE,aAAa,CA+DjF,CAAA"}
@@ -0,0 +1,107 @@
1
+ /**
2
+ * `search_catalog` tool — keyword / hybrid / semantic search across a
3
+ * vertical. Wraps `executeSemanticSearch` from `@voyantjs/catalog-rag`.
4
+ *
5
+ * Visibility filtering and audience-pool enforcement happen at the
6
+ * underlying API layer; the tool just wires args through.
7
+ */
8
+ import { executeSemanticSearch } from "@voyantjs/catalog-rag";
9
+ import { z } from "zod";
10
+ import { enforceAudienceAuthorization, requireService } from "../registry.js";
11
+ const searchCatalogArgs = z.object({
12
+ vertical: z.string().describe('The catalog vertical to search (e.g. "products", "cruises").'),
13
+ query: z.string().describe("Free-text query."),
14
+ mode: z
15
+ .enum(["keyword", "hybrid", "semantic"])
16
+ .default("hybrid")
17
+ .describe("Search mode. Hybrid blends keyword + vector; semantic is pure vector."),
18
+ limit: z.number().int().positive().max(100).default(20).describe("Max results."),
19
+ filters: z
20
+ .array(z.object({
21
+ field: z.string(),
22
+ op: z.enum(["eq", "in", "range"]),
23
+ value: z.unknown(),
24
+ }))
25
+ .optional()
26
+ .describe("Optional filter expressions (engine-translated)."),
27
+ });
28
+ export const searchCatalogTool = {
29
+ name: "search_catalog",
30
+ description: "Search a catalog vertical for sellable inventory matching a free-text query. " +
31
+ "Supports keyword, hybrid (keyword + semantic), or pure semantic search modes. " +
32
+ "Results are visibility-filtered for the calling actor; non-staff actors only see their own audience's view.",
33
+ inputSchema: searchCatalogArgs,
34
+ async handler(args, context) {
35
+ const indexer = requireService(context.catalog.indexer, "indexer");
36
+ const slice = (context.catalog.defaultSliceFor ??
37
+ ((vertical) => ({
38
+ vertical,
39
+ locale: context.defaultScope.locale,
40
+ audience: context.defaultScope.audience,
41
+ market: context.defaultScope.market,
42
+ })))(args.vertical, context.defaultScope);
43
+ enforceAudienceAuthorization(context.actor, [slice.audience]);
44
+ const results = args.mode === "keyword"
45
+ ? await indexer.search(slice, {
46
+ query: args.query,
47
+ mode: "keyword",
48
+ pagination: { limit: args.limit },
49
+ filters: args.filters?.map(toSearchFilter),
50
+ })
51
+ : await executeSemanticSearch({
52
+ adapter: indexer,
53
+ embeddings: requireService(context.catalog.embeddings, "embeddings"),
54
+ slice,
55
+ request: {
56
+ query: args.query,
57
+ mode: args.mode,
58
+ pagination: { limit: args.limit },
59
+ filters: args.filters?.map(toSearchFilter),
60
+ },
61
+ });
62
+ const summary = `Found ${results.hits.length} result(s) in ${args.vertical}.`;
63
+ const lines = results.hits.slice(0, args.limit).map((hit, i) => {
64
+ const title = hit.document.fields.title ??
65
+ hit.document.fields.name ??
66
+ hit.id;
67
+ return `${i + 1}. ${title} (id: ${hit.id}, score: ${hit.score.toFixed(2)})`;
68
+ });
69
+ return {
70
+ content: [{ type: "text", text: [summary, ...lines].join("\n") }],
71
+ structuredContent: {
72
+ vertical: args.vertical,
73
+ total: results.total,
74
+ hits: results.hits.slice(0, args.limit).map((hit) => ({
75
+ id: hit.id,
76
+ score: hit.score,
77
+ fields: hit.document.fields,
78
+ })),
79
+ },
80
+ };
81
+ },
82
+ };
83
+ function toSearchFilter(filter) {
84
+ switch (filter.op) {
85
+ case "eq":
86
+ return {
87
+ kind: "eq",
88
+ field: filter.field,
89
+ value: filter.value,
90
+ };
91
+ case "in":
92
+ return {
93
+ kind: "in",
94
+ field: filter.field,
95
+ values: filter.value,
96
+ };
97
+ case "range": {
98
+ const range = filter.value;
99
+ return {
100
+ kind: "range",
101
+ field: filter.field,
102
+ gte: range.gte,
103
+ lte: range.lte,
104
+ };
105
+ }
106
+ }
107
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * `suggest_alternatives` tool — semantic similarity for "more like this".
3
+ * Fetches the seed entity, then runs a semantic search using a synthesized
4
+ * query built from the entity's title + description.
5
+ *
6
+ * Useful for AI agents asked "show me products like X" — the agent calls
7
+ * this rather than crafting its own search query.
8
+ */
9
+ import { z } from "zod";
10
+ import type { McpToolDefinition, McpToolResult } from "../contract.js";
11
+ declare const suggestAlternativesArgs: z.ZodObject<{
12
+ vertical: z.ZodString;
13
+ seedEntityId: z.ZodString;
14
+ limit: z.ZodDefault<z.ZodNumber>;
15
+ }, z.core.$strip>;
16
+ export type SuggestAlternativesArgs = z.infer<typeof suggestAlternativesArgs>;
17
+ export declare const suggestAlternativesTool: McpToolDefinition<SuggestAlternativesArgs, McpToolResult>;
18
+ export {};
19
+ //# sourceMappingURL=suggest-alternatives.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"suggest-alternatives.d.ts","sourceRoot":"","sources":["../../src/tools/suggest-alternatives.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAGH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAEvB,OAAO,KAAK,EAAE,iBAAiB,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAA;AAGtE,QAAA,MAAM,uBAAuB;;;;iBAI3B,CAAA;AAEF,MAAM,MAAM,uBAAuB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,uBAAuB,CAAC,CAAA;AAE7E,eAAO,MAAM,uBAAuB,EAAE,iBAAiB,CAAC,uBAAuB,EAAE,aAAa,CAsF7F,CAAA"}
@@ -0,0 +1,90 @@
1
+ /**
2
+ * `suggest_alternatives` tool — semantic similarity for "more like this".
3
+ * Fetches the seed entity, then runs a semantic search using a synthesized
4
+ * query built from the entity's title + description.
5
+ *
6
+ * Useful for AI agents asked "show me products like X" — the agent calls
7
+ * this rather than crafting its own search query.
8
+ */
9
+ import { executeSemanticSearch } from "@voyantjs/catalog-rag";
10
+ import { z } from "zod";
11
+ import { enforceAudienceAuthorization, requireService } from "../registry.js";
12
+ const suggestAlternativesArgs = z.object({
13
+ vertical: z.string().describe("The catalog vertical to search."),
14
+ seedEntityId: z.string().describe("Id of the entity to find alternatives for."),
15
+ limit: z.number().int().positive().max(50).default(10).describe("Max alternatives to return."),
16
+ });
17
+ export const suggestAlternativesTool = {
18
+ name: "suggest_alternatives",
19
+ description: "Find catalog entries semantically similar to a seed entity. The seed's title + description " +
20
+ "are vectorized and matched against the audience's embedding pool. The seed itself is excluded from results.",
21
+ inputSchema: suggestAlternativesArgs,
22
+ async handler(args, context) {
23
+ const indexer = requireService(context.catalog.indexer, "indexer");
24
+ const embeddings = requireService(context.catalog.embeddings, "embeddings");
25
+ const resolveEntity = requireService(context.catalog.resolveEntity, "resolveEntity");
26
+ const seed = await resolveEntity(args.vertical, args.seedEntityId, context.defaultScope);
27
+ if (!seed) {
28
+ return {
29
+ isError: true,
30
+ content: [
31
+ {
32
+ type: "text",
33
+ text: `[NOT_FOUND] No ${args.vertical} entity with id "${args.seedEntityId}".`,
34
+ },
35
+ ],
36
+ structuredContent: {
37
+ error: {
38
+ code: "NOT_FOUND",
39
+ vertical: args.vertical,
40
+ entityId: args.seedEntityId,
41
+ },
42
+ },
43
+ };
44
+ }
45
+ const titlePart = seed.fields.title ?? seed.fields.name ?? "";
46
+ const descPart = seed.fields.description ??
47
+ seed.fields.shortDescription ??
48
+ "";
49
+ const query = `${titlePart}\n${descPart}`.trim();
50
+ const slice = (context.catalog.defaultSliceFor ??
51
+ ((vertical) => ({
52
+ vertical,
53
+ locale: context.defaultScope.locale,
54
+ audience: context.defaultScope.audience,
55
+ market: context.defaultScope.market,
56
+ })))(args.vertical, context.defaultScope);
57
+ enforceAudienceAuthorization(context.actor, [slice.audience]);
58
+ const results = await executeSemanticSearch({
59
+ adapter: indexer,
60
+ embeddings,
61
+ slice,
62
+ request: {
63
+ query,
64
+ mode: "semantic",
65
+ pagination: { limit: args.limit + 1 },
66
+ },
67
+ });
68
+ // Drop the seed itself if it surfaces in results.
69
+ const filtered = results.hits.filter((hit) => hit.id !== args.seedEntityId).slice(0, args.limit);
70
+ const summary = `Found ${filtered.length} alternative(s) similar to ${titlePart || args.seedEntityId}.`;
71
+ const lines = filtered.map((hit, i) => {
72
+ const t = hit.document.fields.title ??
73
+ hit.document.fields.name ??
74
+ hit.id;
75
+ return `${i + 1}. ${t} (id: ${hit.id}, similarity: ${hit.score.toFixed(2)})`;
76
+ });
77
+ return {
78
+ content: [{ type: "text", text: [summary, ...lines].join("\n") }],
79
+ structuredContent: {
80
+ vertical: args.vertical,
81
+ seedEntityId: args.seedEntityId,
82
+ alternatives: filtered.map((hit) => ({
83
+ id: hit.id,
84
+ score: hit.score,
85
+ fields: hit.document.fields,
86
+ })),
87
+ },
88
+ };
89
+ },
90
+ };
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=tools.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tools.test.d.ts","sourceRoot":"","sources":["../../src/tools/tools.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,217 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { createMcpToolRegistry } from "../registry.js";
3
+ import { checkAvailabilityTool } from "./check-availability.js";
4
+ import { getEntityTool } from "./get-entity.js";
5
+ import { getQuoteTool } from "./get-quote.js";
6
+ import { searchCatalogTool } from "./search-catalog.js";
7
+ import { suggestAlternativesTool } from "./suggest-alternatives.js";
8
+ const indexerCapabilities = {
9
+ supportsKeywordSearch: true,
10
+ supportsHybridSearch: true,
11
+ supportsVectorFields: true,
12
+ vectorDimensions: 3,
13
+ maxVectorsPerDocument: null,
14
+ supportsCrossAudienceFederation: false,
15
+ supportsAdminDenormalization: true,
16
+ };
17
+ function makeContext(overrides = {}) {
18
+ return {
19
+ actor: "staff",
20
+ tenantId: "op_test",
21
+ defaultScope: { locale: "en-GB", audience: "staff", market: "default", actor: "staff" },
22
+ catalog: overrides,
23
+ };
24
+ }
25
+ function hit(id, score, fields = {}) {
26
+ return { id, score, document: { id, fields: { id, ...fields } } };
27
+ }
28
+ function makeIndexer(hits = []) {
29
+ return {
30
+ capabilities: indexerCapabilities,
31
+ async ensureCollection() { },
32
+ async upsert() { },
33
+ async delete() { },
34
+ async bulkReindex() { },
35
+ async search() {
36
+ return { hits, total: hits.length };
37
+ },
38
+ };
39
+ }
40
+ const stubEmbeddings = {
41
+ capabilities: {
42
+ modelId: "stub/v1",
43
+ dimensions: 3,
44
+ maxTokensPerInput: 1000,
45
+ maxBatchSize: 100,
46
+ },
47
+ async embed(texts) {
48
+ return texts.map(() => [0.1, 0.2, 0.3]);
49
+ },
50
+ };
51
+ describe("searchCatalogTool", () => {
52
+ it("returns hits as text + structured content", async () => {
53
+ const context = makeContext({
54
+ indexer: makeIndexer([hit("a", 0.9, { title: "Alpha" }), hit("b", 0.7, { title: "Beta" })]),
55
+ embeddings: stubEmbeddings,
56
+ });
57
+ const registry = createMcpToolRegistry({ context });
58
+ registry.register(searchCatalogTool);
59
+ const result = await registry.dispatchTool("search_catalog", {
60
+ vertical: "products",
61
+ query: "wellness",
62
+ mode: "keyword",
63
+ });
64
+ expect(result.isError).toBeUndefined();
65
+ expect(result.structuredContent?.total).toBe(2);
66
+ expect((result.structuredContent?.hits)[0]?.id).toBe("a");
67
+ });
68
+ it("MISSING_SERVICE error when indexer not configured", async () => {
69
+ const context = makeContext({ embeddings: stubEmbeddings });
70
+ const registry = createMcpToolRegistry({ context });
71
+ registry.register(searchCatalogTool);
72
+ const result = await registry.dispatchTool("search_catalog", {
73
+ vertical: "products",
74
+ query: "wellness",
75
+ });
76
+ expect(result.isError).toBe(true);
77
+ expect(result.structuredContent?.error).toMatchObject({ code: "MISSING_SERVICE" });
78
+ });
79
+ it("MISSING_SERVICE for embeddings when mode requires them", async () => {
80
+ const context = makeContext({ indexer: makeIndexer() });
81
+ const registry = createMcpToolRegistry({ context });
82
+ registry.register(searchCatalogTool);
83
+ const result = await registry.dispatchTool("search_catalog", {
84
+ vertical: "products",
85
+ query: "x",
86
+ mode: "semantic",
87
+ });
88
+ expect(result.isError).toBe(true);
89
+ expect(result.structuredContent?.error).toMatchObject({ code: "MISSING_SERVICE" });
90
+ });
91
+ });
92
+ describe("getEntityTool", () => {
93
+ it("returns the resolved view with title + description", async () => {
94
+ const resolveEntity = vi.fn(async (vertical, entityId) => ({
95
+ vertical,
96
+ entityId,
97
+ fields: { title: "Bali Wellness", description: "Source description" },
98
+ }));
99
+ const context = makeContext({ resolveEntity });
100
+ const registry = createMcpToolRegistry({ context });
101
+ registry.register(getEntityTool);
102
+ const result = await registry.dispatchTool("get_entity", {
103
+ vertical: "products",
104
+ entityId: "prod_xyz",
105
+ });
106
+ expect(result.isError).toBeUndefined();
107
+ expect(result.structuredContent?.entityId).toBe("prod_xyz");
108
+ expect((result.structuredContent?.fields).title).toBe("Bali Wellness");
109
+ });
110
+ it("returns NOT_FOUND when the entity doesn't exist", async () => {
111
+ const resolveEntity = vi.fn(async () => null);
112
+ const context = makeContext({ resolveEntity });
113
+ const registry = createMcpToolRegistry({ context });
114
+ registry.register(getEntityTool);
115
+ const result = await registry.dispatchTool("get_entity", {
116
+ vertical: "products",
117
+ entityId: "phantom",
118
+ });
119
+ expect(result.isError).toBe(true);
120
+ expect(result.structuredContent?.error).toMatchObject({ code: "NOT_FOUND" });
121
+ });
122
+ });
123
+ describe("suggestAlternativesTool", () => {
124
+ it("excludes the seed entity from results", async () => {
125
+ const resolveEntity = vi.fn(async (vertical, entityId) => ({
126
+ vertical,
127
+ entityId,
128
+ fields: { title: "Bali Wellness", description: "yoga retreat" },
129
+ }));
130
+ const context = makeContext({
131
+ indexer: makeIndexer([
132
+ hit("seed_xyz", 0.99, { title: "Bali Wellness" }),
133
+ hit("alt_1", 0.85, { title: "Bali Yoga" }),
134
+ hit("alt_2", 0.75, { title: "Ubud Spa" }),
135
+ ]),
136
+ embeddings: stubEmbeddings,
137
+ resolveEntity,
138
+ });
139
+ const registry = createMcpToolRegistry({ context });
140
+ registry.register(suggestAlternativesTool);
141
+ const result = await registry.dispatchTool("suggest_alternatives", {
142
+ vertical: "products",
143
+ seedEntityId: "seed_xyz",
144
+ limit: 5,
145
+ });
146
+ expect(result.isError).toBeUndefined();
147
+ const alts = result.structuredContent?.alternatives;
148
+ expect(alts).toHaveLength(2);
149
+ expect(alts.map((a) => a.id)).not.toContain("seed_xyz");
150
+ });
151
+ it("returns NOT_FOUND when the seed entity doesn't exist", async () => {
152
+ const context = makeContext({
153
+ indexer: makeIndexer(),
154
+ embeddings: stubEmbeddings,
155
+ resolveEntity: async () => null,
156
+ });
157
+ const registry = createMcpToolRegistry({ context });
158
+ registry.register(suggestAlternativesTool);
159
+ const result = await registry.dispatchTool("suggest_alternatives", {
160
+ vertical: "products",
161
+ seedEntityId: "phantom",
162
+ });
163
+ expect(result.isError).toBe(true);
164
+ expect(result.structuredContent?.error).toMatchObject({ code: "NOT_FOUND" });
165
+ });
166
+ });
167
+ describe("checkAvailabilityTool", () => {
168
+ it("calls the live availability function and surfaces availability", async () => {
169
+ const checkAvailability = vi.fn(async () => ({
170
+ available: true,
171
+ details: { rooms_left: 3 },
172
+ checkedAt: "2026-09-01T12:00:00Z",
173
+ }));
174
+ const context = makeContext({ checkAvailability });
175
+ const registry = createMcpToolRegistry({ context });
176
+ registry.register(checkAvailabilityTool);
177
+ const result = await registry.dispatchTool("check_availability", {
178
+ vertical: "hospitality",
179
+ entityId: "rmtp_xyz",
180
+ parameters: { dates: "2026-10-15..2026-10-22" },
181
+ });
182
+ expect(result.isError).toBeUndefined();
183
+ expect(result.structuredContent?.available).toBe(true);
184
+ });
185
+ });
186
+ describe("getQuoteTool", () => {
187
+ it("returns the locked quote with expiry timestamp", async () => {
188
+ const getQuote = vi.fn(async () => ({
189
+ quoteId: "q_abc",
190
+ totalPrice: { amount: "1500.00", currency: "EUR" },
191
+ expiresAt: "2026-09-01T13:00:00Z",
192
+ }));
193
+ const context = makeContext({ getQuote });
194
+ const registry = createMcpToolRegistry({ context });
195
+ registry.register(getQuoteTool);
196
+ const result = await registry.dispatchTool("get_quote", {
197
+ vertical: "products",
198
+ entityId: "prod_xyz",
199
+ parameters: { dates: "2026-10-15..2026-10-22", pax: 2 },
200
+ });
201
+ expect(result.isError).toBeUndefined();
202
+ expect(result.structuredContent?.quoteId).toBe("q_abc");
203
+ expect((result.structuredContent?.totalPrice).amount).toBe("1500.00");
204
+ });
205
+ it("MISSING_SERVICE when getQuote function not configured", async () => {
206
+ const context = makeContext({});
207
+ const registry = createMcpToolRegistry({ context });
208
+ registry.register(getQuoteTool);
209
+ const result = await registry.dispatchTool("get_quote", {
210
+ vertical: "products",
211
+ entityId: "x",
212
+ parameters: {},
213
+ });
214
+ expect(result.isError).toBe(true);
215
+ expect(result.structuredContent?.error).toMatchObject({ code: "MISSING_SERVICE" });
216
+ });
217
+ });