@sendinel/mcp-server 1.0.81 → 1.0.83
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.
- package/build/tools/contacts.js +71 -17
- package/build/tools/gateway.d.ts +3 -3
- package/build/tools/gateway.js +28 -25
- package/build/tools/tool-search.d.ts +40 -0
- package/build/tools/tool-search.js +140 -0
- package/package.json +1 -1
package/build/tools/contacts.js
CHANGED
|
@@ -1140,7 +1140,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1140
1140
|
idempotentHint: false,
|
|
1141
1141
|
openWorldHint: false,
|
|
1142
1142
|
},
|
|
1143
|
-
description: 'Add a contact and subscribe them to a site',
|
|
1143
|
+
description: 'Add a contact and subscribe them to a site. Existing contacts keep their source and stored fields; the response includes existing: true.',
|
|
1144
1144
|
inputSchema: {
|
|
1145
1145
|
email: z.string().email(),
|
|
1146
1146
|
site_id: z.string().uuid(),
|
|
@@ -1157,6 +1157,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1157
1157
|
utm_term: z.string().optional(),
|
|
1158
1158
|
},
|
|
1159
1159
|
}, async ({ email, site_id, first_name, last_name, company_id, company_name, company_domain, source, utm_source, utm_medium, utm_campaign, utm_content, utm_term }) => {
|
|
1160
|
+
email = email.trim().toLowerCase();
|
|
1160
1161
|
const projectId = getProjectId();
|
|
1161
1162
|
const embedOk = await hasCompaniesEmbed(projectId);
|
|
1162
1163
|
const resolvedCompanyId = await resolveCompanyId(projectId, { company_id, company_name, company_domain });
|
|
@@ -1174,23 +1175,30 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1174
1175
|
// Check if site requires double opt-in
|
|
1175
1176
|
const { data: siteInfo } = await db().from('sites').select('double_opt_in').eq('id', site_id).maybeSingle();
|
|
1176
1177
|
const needsConfirm = siteInfo?.double_opt_in === true;
|
|
1177
|
-
|
|
1178
|
+
let { data: existingContact, error: lookupError } = await db()
|
|
1178
1179
|
.from('subscribers')
|
|
1179
|
-
.select('id')
|
|
1180
|
+
.select('id, source, first_name, last_name, company_id, custom_properties, opted_in_at')
|
|
1180
1181
|
.eq('project_id', projectId)
|
|
1181
1182
|
.eq('email', email)
|
|
1182
1183
|
.maybeSingle();
|
|
1183
|
-
|
|
1184
|
+
if (lookupError)
|
|
1185
|
+
return txt(`Error looking up contact: ${lookupError.message}`);
|
|
1186
|
+
let customProperties;
|
|
1184
1187
|
if (company_name || company_domain) {
|
|
1185
1188
|
const orgId = await getProjectOrgId(projectId);
|
|
1186
|
-
|
|
1189
|
+
customProperties = await normalizeMcpContactPropertyPatch({
|
|
1187
1190
|
...(company_name ? { company_name } : {}),
|
|
1188
1191
|
...(company_domain ? { company_domain: company_domain.trim().toLowerCase() } : {}),
|
|
1189
1192
|
}, { orgId, projectId });
|
|
1190
1193
|
}
|
|
1191
|
-
|
|
1192
|
-
|
|
1194
|
+
const selection = embedOk ? '*, company:companies(id, name, domain)' : '*';
|
|
1195
|
+
let contact;
|
|
1193
1196
|
if (!existingContact) {
|
|
1197
|
+
const row = { project_id: projectId, email, first_name, last_name, company_id: resolvedCompanyId, source, source_site: site_id };
|
|
1198
|
+
if (customProperties)
|
|
1199
|
+
row.custom_properties = customProperties;
|
|
1200
|
+
if (!needsConfirm)
|
|
1201
|
+
row.opted_in_at = new Date().toISOString();
|
|
1194
1202
|
if (utm_source)
|
|
1195
1203
|
row.utm_source = utm_source;
|
|
1196
1204
|
if (utm_medium)
|
|
@@ -1201,30 +1209,76 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1201
1209
|
row.utm_content = utm_content;
|
|
1202
1210
|
if (utm_term)
|
|
1203
1211
|
row.utm_term = utm_term;
|
|
1212
|
+
const { data, error } = await db().from('subscribers')
|
|
1213
|
+
.upsert(row, { onConflict: 'project_id,email', ignoreDuplicates: true })
|
|
1214
|
+
.select(selection).maybeSingle();
|
|
1215
|
+
if (error)
|
|
1216
|
+
return txt(`Error creating contact: ${error.message}`);
|
|
1217
|
+
contact = data;
|
|
1218
|
+
if (!contact) {
|
|
1219
|
+
// A concurrent add won the unique key; its provenance belongs to that insert.
|
|
1220
|
+
const result = await db().from('subscribers')
|
|
1221
|
+
.select('id, source, first_name, last_name, company_id, custom_properties, opted_in_at')
|
|
1222
|
+
.eq('project_id', projectId).eq('email', email).single();
|
|
1223
|
+
if (result.error)
|
|
1224
|
+
return txt(`Error looking up contact: ${result.error.message}`);
|
|
1225
|
+
existingContact = result.data;
|
|
1226
|
+
}
|
|
1204
1227
|
}
|
|
1205
|
-
const
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
.
|
|
1210
|
-
|
|
1211
|
-
|
|
1228
|
+
const existing = !!existingContact;
|
|
1229
|
+
if (existingContact) {
|
|
1230
|
+
const empty = (value) => value == null || value === '';
|
|
1231
|
+
const row = {};
|
|
1232
|
+
for (const [key, value] of Object.entries({ first_name, last_name, company_id: resolvedCompanyId })) {
|
|
1233
|
+
if (empty(existingContact[key]) && !empty(value))
|
|
1234
|
+
row[key] = value;
|
|
1235
|
+
}
|
|
1236
|
+
if (!needsConfirm && existingContact.opted_in_at == null)
|
|
1237
|
+
row.opted_in_at = new Date().toISOString();
|
|
1238
|
+
const missingProperties = Object.fromEntries(Object.entries(customProperties ?? {})
|
|
1239
|
+
.filter(([key]) => !Object.hasOwn(existingContact.custom_properties ?? {}, key)));
|
|
1240
|
+
if (Object.keys(missingProperties).length) {
|
|
1241
|
+
row.custom_properties = { ...(existingContact.custom_properties ?? {}), ...missingProperties };
|
|
1242
|
+
}
|
|
1243
|
+
if (Object.keys(row).length) {
|
|
1244
|
+
let update = db().from('subscribers').update(row)
|
|
1245
|
+
.eq('id', existingContact.id).eq('project_id', projectId);
|
|
1246
|
+
// The stored values must still match: a concurrent edit always wins over a re-add.
|
|
1247
|
+
for (const key of Object.keys(row)) {
|
|
1248
|
+
const previous = existingContact[key];
|
|
1249
|
+
update = previous == null ? update.is(key, null)
|
|
1250
|
+
: update.eq(key, key === 'custom_properties' ? JSON.stringify(previous) : previous);
|
|
1251
|
+
}
|
|
1252
|
+
const { error } = await update;
|
|
1253
|
+
if (error)
|
|
1254
|
+
return txt(`Error updating contact: ${error.message}`);
|
|
1255
|
+
}
|
|
1256
|
+
const result = await db().from('subscribers').select(selection)
|
|
1257
|
+
.eq('id', existingContact.id).eq('project_id', projectId).single();
|
|
1258
|
+
if (result.error)
|
|
1259
|
+
return txt(`Error reading contact: ${result.error.message}`);
|
|
1260
|
+
contact = result.data;
|
|
1261
|
+
}
|
|
1262
|
+
const existingMessage = existing ? { message: 'Contact already existed; source and existing fields were not changed.' } : {};
|
|
1212
1263
|
const { error: subErr } = await db()
|
|
1213
1264
|
.from('site_subscriptions')
|
|
1214
1265
|
.upsert({ contact_id: contact.id, site_id, subscribed: !needsConfirm }, { onConflict: 'contact_id,site_id' });
|
|
1215
1266
|
if (subErr)
|
|
1216
1267
|
return { content: [{ type: 'text', text: `Contact created but subscription failed: ${subErr.message}` }] };
|
|
1268
|
+
if (existing)
|
|
1269
|
+
logAudit({ action: 'contact.readded', resourceType: 'contact', resourceId: contact.id, details: { email, requested_source: source, existing: true } });
|
|
1217
1270
|
if (needsConfirm) {
|
|
1218
1271
|
// Create opt-in token (confirmation email must be sent externally in MCP context)
|
|
1219
1272
|
await db().from('opt_in_tokens').insert({ contact_id: contact.id, site_id });
|
|
1220
1273
|
await writeBackMcpContact(projectId, contact, { email, first_name: contact.first_name ?? null, last_name: contact.last_name ?? null });
|
|
1221
1274
|
logConsent({ contactId: contact.id, siteId: site_id, action: 'grant', consentType: 'email_marketing', source: 'mcp_pending_double_opt_in' });
|
|
1222
|
-
return { content: [{ type: 'text', text: JSON.stringify({ ...contact, confirmation: 'pending', message: 'Double opt-in required. Confirmation token created.' }, null, 2) }] };
|
|
1275
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ...contact, existing, confirmation: 'pending', message: 'Double opt-in required. Confirmation token created.', ...existingMessage }, null, 2) }] };
|
|
1223
1276
|
}
|
|
1224
1277
|
await writeBackMcpContact(projectId, contact, { email, first_name: contact.first_name ?? null, last_name: contact.last_name ?? null });
|
|
1225
1278
|
logConsent({ contactId: contact.id, siteId: site_id, action: 'grant', consentType: 'email_marketing', source: 'mcp_add_subscriber' });
|
|
1226
|
-
|
|
1227
|
-
|
|
1279
|
+
if (!existing)
|
|
1280
|
+
logAudit({ action: 'contact.created', resourceType: 'contact', resourceId: contact.id, details: { email, source } });
|
|
1281
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ...contact, existing, ...existingMessage }, null, 2) }] };
|
|
1228
1282
|
});
|
|
1229
1283
|
server.registerTool('import_subscribers', {
|
|
1230
1284
|
title: 'Import Subscribers',
|
package/build/tools/gateway.d.ts
CHANGED
|
@@ -4,11 +4,11 @@ export interface GatewayOptions {
|
|
|
4
4
|
toolGroups?: string[];
|
|
5
5
|
}
|
|
6
6
|
/**
|
|
7
|
-
* Plain words people use for a tool,
|
|
8
|
-
*
|
|
7
|
+
* Plain words people use for a tool (#3669, #4059). The map lives in tool-search.ts so how_to can
|
|
8
|
+
* share it; re-exported under its original name for compatibility.
|
|
9
9
|
*/
|
|
10
10
|
export declare const INTENT_ALIASES: Record<string, string[]>;
|
|
11
|
-
/**
|
|
11
|
+
/** True when the tool scores above zero for the query (OR semantics; empty query matches everything). */
|
|
12
12
|
export declare function matchesToolQuery(entry: {
|
|
13
13
|
name: string;
|
|
14
14
|
group: string;
|
package/build/tools/gateway.js
CHANGED
|
@@ -3,6 +3,7 @@ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
|
|
|
3
3
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
import { getRegisteredToolGroups, registerAllTools } from '../server.js';
|
|
6
|
+
import { rankTools, scoreTool, TOOL_ALIASES, tokenize } from './tool-search.js';
|
|
6
7
|
const textResult = (text) => ({ content: [{ type: 'text', text }] });
|
|
7
8
|
const jsonResult = (data) => textResult(JSON.stringify(data, null, 2));
|
|
8
9
|
const notFound = (message) => ({
|
|
@@ -10,28 +11,17 @@ const notFound = (message) => ({
|
|
|
10
11
|
content: [{ type: 'text', text: message }],
|
|
11
12
|
});
|
|
12
13
|
/**
|
|
13
|
-
* Plain words people use for a tool,
|
|
14
|
-
*
|
|
14
|
+
* Plain words people use for a tool (#3669, #4059). The map lives in tool-search.ts so how_to can
|
|
15
|
+
* share it; re-exported under its original name for compatibility.
|
|
15
16
|
*/
|
|
16
|
-
export const INTENT_ALIASES =
|
|
17
|
-
|
|
18
|
-
list_campaigns: ['last campaign', 'latest campaign', 'recent campaign', 'my campaigns'],
|
|
19
|
-
get_campaign: ['last campaign', 'campaign details', 'campaign results'],
|
|
20
|
-
create_campaign: ['welcome', 'welcome email', 'onboarding', 'drip', 'sequence', 'triggered email'],
|
|
21
|
-
add_campaign_step: ['welcome', 'onboarding', 'follow up email', 'next email in sequence'],
|
|
22
|
-
};
|
|
23
|
-
/** Every word of the query must appear somewhere in the tool's text (name, group, description, aliases). */
|
|
17
|
+
export const INTENT_ALIASES = TOOL_ALIASES;
|
|
18
|
+
/** True when the tool scores above zero for the query (OR semantics; empty query matches everything). */
|
|
24
19
|
export function matchesToolQuery(entry, query) {
|
|
25
|
-
|
|
26
|
-
if (!q)
|
|
20
|
+
if (!query.trim())
|
|
27
21
|
return true;
|
|
28
|
-
|
|
29
|
-
.join(' \n ')
|
|
30
|
-
.toLowerCase();
|
|
31
|
-
if (haystack.includes(q))
|
|
32
|
-
return true;
|
|
33
|
-
return q.split(/\s+/).filter(Boolean).every((token) => haystack.includes(token));
|
|
22
|
+
return scoreTool(entry, tokenize(query), query) > 0;
|
|
34
23
|
}
|
|
24
|
+
const SEARCH_LIMIT = 10;
|
|
35
25
|
async function buildCatalog(options) {
|
|
36
26
|
const innerServer = new McpServer({ name: 'sendinel-gateway-inner', version: '1.0.0' });
|
|
37
27
|
registerAllTools(innerServer, { scopes: options.scopes, toolGroups: options.toolGroups });
|
|
@@ -101,14 +91,27 @@ export function registerGatewayTools(server, options) {
|
|
|
101
91
|
const catalog = await getCatalog();
|
|
102
92
|
const q = query?.trim().toLowerCase() ?? '';
|
|
103
93
|
const requestedGroup = group?.trim();
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
return
|
|
108
|
-
}
|
|
94
|
+
const scoped = catalog.entries.filter((tool) => !requestedGroup || tool.group === requestedGroup);
|
|
95
|
+
const summarize = ({ name, group, description }) => ({ name, group, description });
|
|
96
|
+
if (!q) {
|
|
97
|
+
return jsonResult({ tools: scoped.map(summarize), total: scoped.length });
|
|
98
|
+
}
|
|
99
|
+
const { matches, nearest } = rankTools(scoped, q);
|
|
100
|
+
if (matches.length === 0) {
|
|
101
|
+
return jsonResult({
|
|
102
|
+
tools: [],
|
|
103
|
+
total: 0,
|
|
104
|
+
none_matched: true,
|
|
105
|
+
nearest: nearest.map(({ entry }) => summarize(entry)),
|
|
106
|
+
hint: 'No tool matched that phrasing. The nearest tools are listed; try a shorter keyword, or browse with the group filter.',
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
const top = matches.slice(0, SEARCH_LIMIT);
|
|
109
110
|
return jsonResult({
|
|
110
|
-
tools:
|
|
111
|
-
total:
|
|
111
|
+
tools: top.map(({ entry }) => summarize(entry)),
|
|
112
|
+
total: matches.length,
|
|
113
|
+
ranked: true,
|
|
114
|
+
...(matches.length > top.length ? { truncated: true } : {}),
|
|
112
115
|
});
|
|
113
116
|
});
|
|
114
117
|
server.registerTool('describe_tool', {
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ranked plain-language tool search (#4059). Shared by search_tools today and by how_to later:
|
|
3
|
+
* tokenize -> drop stopwords -> stem simple plurals -> score (OR semantics) -> rank.
|
|
4
|
+
*/
|
|
5
|
+
export interface SearchableTool {
|
|
6
|
+
name: string;
|
|
7
|
+
group: string;
|
|
8
|
+
description: string;
|
|
9
|
+
}
|
|
10
|
+
export interface ScoredTool<T extends SearchableTool = SearchableTool> {
|
|
11
|
+
entry: T;
|
|
12
|
+
score: number;
|
|
13
|
+
}
|
|
14
|
+
export interface RankResult<T extends SearchableTool = SearchableTool> {
|
|
15
|
+
/** Every tool with score > 0, best first (not truncated). */
|
|
16
|
+
matches: ScoredTool<T>[];
|
|
17
|
+
/** Closest tools when nothing matched; empty only when `entries` is empty. */
|
|
18
|
+
nearest: ScoredTool<T>[];
|
|
19
|
+
}
|
|
20
|
+
/** Exactly the list from #4059. */
|
|
21
|
+
export declare const STOPWORDS: ReadonlySet<string>;
|
|
22
|
+
/**
|
|
23
|
+
* Plain words people use for a tool (#3669, #4059). One map, shared with how_to's recipe synonyms.
|
|
24
|
+
* Only tools that exist in the registry belong here.
|
|
25
|
+
*/
|
|
26
|
+
export declare const TOOL_ALIASES: Record<string, string[]>;
|
|
27
|
+
/** Simple plurals only; leaves the word alone when stripping would leave fewer than 4 characters. */
|
|
28
|
+
export declare function stem(token: string): string;
|
|
29
|
+
/** Lowercase, split, drop stopwords, stem. A query made only of stopwords keeps them so it still searches. */
|
|
30
|
+
export declare function tokenize(query: string): string[];
|
|
31
|
+
/**
|
|
32
|
+
* Tiers are strictly ordered: exact name > alias phrase > per-token hits (name > alias > group >
|
|
33
|
+
* description, best tier per token, summed: OR semantics). 0 means no match.
|
|
34
|
+
*/
|
|
35
|
+
export declare function scoreTool(entry: SearchableTool, queryTokens: string[], rawQuery: string, aliases?: Record<string, string[]>): number;
|
|
36
|
+
export declare function rankTools<T extends SearchableTool>(entries: T[], query: string, options?: {
|
|
37
|
+
limit?: number;
|
|
38
|
+
aliases?: Record<string, string[]>;
|
|
39
|
+
nearestLimit?: number;
|
|
40
|
+
}): RankResult<T>;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ranked plain-language tool search (#4059). Shared by search_tools today and by how_to later:
|
|
3
|
+
* tokenize -> drop stopwords -> stem simple plurals -> score (OR semantics) -> rank.
|
|
4
|
+
*/
|
|
5
|
+
/** Exactly the list from #4059. */
|
|
6
|
+
export const STOPWORDS = new Set([
|
|
7
|
+
'how', 'do', 'does', 'i', 'my', 'me', 'a', 'an', 'the', 'to', 'of', 'for',
|
|
8
|
+
'why', 'can', 'what', 'is', 'did', 'get', 'please',
|
|
9
|
+
]);
|
|
10
|
+
/**
|
|
11
|
+
* Plain words people use for a tool (#3669, #4059). One map, shared with how_to's recipe synonyms.
|
|
12
|
+
* Only tools that exist in the registry belong here.
|
|
13
|
+
*/
|
|
14
|
+
export const TOOL_ALIASES = {
|
|
15
|
+
send_broadcast: [
|
|
16
|
+
'send email', 'send an email', 'email blast', 'blast', 'newsletter', 'announce', 'announcement', 'one-off email',
|
|
17
|
+
'email my list', 'send to my list', 'email to my list', 'email my subscribers',
|
|
18
|
+
],
|
|
19
|
+
list_campaigns: ['last campaign', 'latest campaign', 'recent campaign', 'my campaigns'],
|
|
20
|
+
get_campaign: [
|
|
21
|
+
'last campaign', 'campaign details', 'campaign results',
|
|
22
|
+
'opened', 'opens', 'open rate', 'results', 'stats',
|
|
23
|
+
],
|
|
24
|
+
get_stats: ['opened', 'opens', 'open rate', 'click rate', 'email stats', 'how many opened', 'performance', 'results'],
|
|
25
|
+
create_campaign: [
|
|
26
|
+
'welcome', 'welcome email', 'onboarding', 'drip', 'sequence', 'triggered email',
|
|
27
|
+
'launch', 'welcome sequence', 'new signups', 'signup email',
|
|
28
|
+
],
|
|
29
|
+
add_campaign_step: [
|
|
30
|
+
'welcome', 'onboarding', 'follow up email', 'next email in sequence',
|
|
31
|
+
'welcome sequence', 'new signups', 'launch',
|
|
32
|
+
],
|
|
33
|
+
diagnose_delivery_issue: [
|
|
34
|
+
'bounce', 'bounced', 'bounces', 'why did my email bounce', 'not delivered', 'delivery problem',
|
|
35
|
+
'delivery issue', 'spam folder', 'email not arriving',
|
|
36
|
+
],
|
|
37
|
+
list_email_log: ['bounce', 'bounced', 'email log', 'delivery status', 'delivery events', 'sent emails'],
|
|
38
|
+
add_subscriber: ['add a contact', 'add contact', 'new contact', 'add person', 'add a person', 'new subscriber'],
|
|
39
|
+
import_subscribers: ['import contacts', 'upload list', 'upload contacts', 'csv', 'import list'],
|
|
40
|
+
};
|
|
41
|
+
/** Simple plurals only; leaves the word alone when stripping would leave fewer than 4 characters. */
|
|
42
|
+
export function stem(token) {
|
|
43
|
+
let out = token;
|
|
44
|
+
if (token.endsWith('ies'))
|
|
45
|
+
out = `${token.slice(0, -3)}y`;
|
|
46
|
+
else if (token.endsWith('sses'))
|
|
47
|
+
out = token.slice(0, -2);
|
|
48
|
+
else if (token.endsWith('s') && !/(ss|us|is)$/.test(token))
|
|
49
|
+
out = token.slice(0, -1);
|
|
50
|
+
return out.length >= 4 ? out : token;
|
|
51
|
+
}
|
|
52
|
+
function splitWords(text) {
|
|
53
|
+
return text.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
|
|
54
|
+
}
|
|
55
|
+
/** Lowercase, split, drop stopwords, stem. A query made only of stopwords keeps them so it still searches. */
|
|
56
|
+
export function tokenize(query) {
|
|
57
|
+
const words = splitWords(query);
|
|
58
|
+
const kept = words.filter((w) => !STOPWORDS.has(w));
|
|
59
|
+
return (kept.length > 0 ? kept : words).map(stem);
|
|
60
|
+
}
|
|
61
|
+
const normalizePhrase = (text) => ` ${splitWords(text).join(' ')} `;
|
|
62
|
+
const SCORE_EXACT_NAME = 100;
|
|
63
|
+
const SCORE_ALIAS_PHRASE = 60;
|
|
64
|
+
const SCORE_NAME_TOKEN = 10;
|
|
65
|
+
const SCORE_ALIAS_TOKEN = 8;
|
|
66
|
+
const SCORE_GROUP_TOKEN = 3;
|
|
67
|
+
const SCORE_DESCRIPTION_TOKEN = 2;
|
|
68
|
+
function toolTokens(entry, aliases) {
|
|
69
|
+
return {
|
|
70
|
+
name: new Set(splitWords(entry.name).map(stem)),
|
|
71
|
+
alias: new Set((aliases[entry.name] ?? []).flatMap((a) => splitWords(a)).map(stem)),
|
|
72
|
+
group: new Set(splitWords(entry.group).map(stem)),
|
|
73
|
+
description: new Set(splitWords(entry.description).filter((w) => !STOPWORDS.has(w)).map(stem)),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Tiers are strictly ordered: exact name > alias phrase > per-token hits (name > alias > group >
|
|
78
|
+
* description, best tier per token, summed: OR semantics). 0 means no match.
|
|
79
|
+
*/
|
|
80
|
+
export function scoreTool(entry, queryTokens, rawQuery, aliases = TOOL_ALIASES) {
|
|
81
|
+
const normalized = normalizePhrase(rawQuery);
|
|
82
|
+
if (normalized.trim() === '')
|
|
83
|
+
return 0;
|
|
84
|
+
if (normalized.trim().replace(/ /g, '_') === entry.name)
|
|
85
|
+
return SCORE_EXACT_NAME;
|
|
86
|
+
let score = 0;
|
|
87
|
+
if ((aliases[entry.name] ?? []).some((phrase) => normalized.includes(normalizePhrase(phrase)))) {
|
|
88
|
+
score += SCORE_ALIAS_PHRASE;
|
|
89
|
+
}
|
|
90
|
+
const tokens = toolTokens(entry, aliases);
|
|
91
|
+
for (const token of new Set(queryTokens)) {
|
|
92
|
+
if (tokens.name.has(token))
|
|
93
|
+
score += SCORE_NAME_TOKEN;
|
|
94
|
+
else if (tokens.alias.has(token))
|
|
95
|
+
score += SCORE_ALIAS_TOKEN;
|
|
96
|
+
else if (tokens.group.has(token))
|
|
97
|
+
score += SCORE_GROUP_TOKEN;
|
|
98
|
+
else if (tokens.description.has(token))
|
|
99
|
+
score += SCORE_DESCRIPTION_TOKEN;
|
|
100
|
+
}
|
|
101
|
+
return score;
|
|
102
|
+
}
|
|
103
|
+
/** Prefix-match fallback (>= 3 chars) used only to pick "nearest" tools when nothing scored. */
|
|
104
|
+
function fuzzyScore(entry, queryTokens, aliases) {
|
|
105
|
+
const tokens = toolTokens(entry, aliases);
|
|
106
|
+
const pools = [tokens.name, tokens.alias, tokens.group, tokens.description];
|
|
107
|
+
let score = 0;
|
|
108
|
+
for (const token of new Set(queryTokens)) {
|
|
109
|
+
if (token.length < 3)
|
|
110
|
+
continue;
|
|
111
|
+
for (const pool of pools) {
|
|
112
|
+
if ([...pool].some((w) => w.startsWith(token) || (w.length >= 3 && token.startsWith(w)))) {
|
|
113
|
+
score += 1;
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return score;
|
|
119
|
+
}
|
|
120
|
+
const byScoreThenName = (a, b) => b.score - a.score || a.entry.name.localeCompare(b.entry.name);
|
|
121
|
+
export function rankTools(entries, query, options = {}) {
|
|
122
|
+
const aliases = options.aliases ?? TOOL_ALIASES;
|
|
123
|
+
const nearestLimit = options.nearestLimit ?? 5;
|
|
124
|
+
const queryTokens = tokenize(query);
|
|
125
|
+
const matches = entries
|
|
126
|
+
.map((entry) => ({ entry, score: scoreTool(entry, queryTokens, query, aliases) }))
|
|
127
|
+
.filter((s) => s.score > 0)
|
|
128
|
+
.sort(byScoreThenName);
|
|
129
|
+
if (matches.length > 0) {
|
|
130
|
+
return { matches: options.limit ? matches.slice(0, options.limit) : matches, nearest: [] };
|
|
131
|
+
}
|
|
132
|
+
const fuzzy = entries
|
|
133
|
+
.map((entry) => ({ entry, score: fuzzyScore(entry, queryTokens, aliases) }))
|
|
134
|
+
.filter((s) => s.score > 0)
|
|
135
|
+
.sort(byScoreThenName);
|
|
136
|
+
const nearest = fuzzy.length > 0
|
|
137
|
+
? fuzzy
|
|
138
|
+
: entries.slice(0, nearestLimit).map((entry) => ({ entry, score: 0 }));
|
|
139
|
+
return { matches: [], nearest: nearest.slice(0, nearestLimit) };
|
|
140
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendinel/mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.83",
|
|
4
4
|
"description": "AI-controlled email operations control plane for campaigns, contacts, segments, analytics, and multi-provider sending via MCP.",
|
|
5
5
|
"mcpName": "ai.sendinel/mcp-server",
|
|
6
6
|
"type": "module",
|