@sendinel/mcp-server 1.0.80 → 1.0.82
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/db.d.ts +2 -0
- package/build/db.js +1 -0
- package/build/tools/analytics.js +5 -0
- 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/db.d.ts
CHANGED
|
@@ -38,6 +38,7 @@ export declare function runWithClient<T>(client: any, fn: () => Promise<T>, requ
|
|
|
38
38
|
actorType?: string;
|
|
39
39
|
actorId?: string | null;
|
|
40
40
|
oauthTokenId?: string | null;
|
|
41
|
+
invitationProjectIds?: string[] | null;
|
|
41
42
|
authKind?: string | null;
|
|
42
43
|
oauthClientId?: string | null;
|
|
43
44
|
agentLabel?: string | null;
|
|
@@ -58,6 +59,7 @@ export declare function requestContext(): {
|
|
|
58
59
|
actorType?: string;
|
|
59
60
|
actorId?: string | null;
|
|
60
61
|
oauthTokenId?: string | null;
|
|
62
|
+
invitationProjectIds?: string[] | null;
|
|
61
63
|
authKind?: string | null;
|
|
62
64
|
oauthClientId?: string | null;
|
|
63
65
|
agentLabel?: string | null;
|
package/build/db.js
CHANGED
|
@@ -69,6 +69,7 @@ export function requestContext() {
|
|
|
69
69
|
actorType: ctx.actorType,
|
|
70
70
|
actorId: ctx.actorId,
|
|
71
71
|
oauthTokenId: ctx.oauthTokenId,
|
|
72
|
+
invitationProjectIds: ctx.invitationProjectIds,
|
|
72
73
|
authKind: ctx.authKind,
|
|
73
74
|
oauthClientId: ctx.oauthClientId,
|
|
74
75
|
agentLabel: ctx.agentLabel,
|
package/build/tools/analytics.js
CHANGED
|
@@ -365,6 +365,8 @@ export function registerAnalyticsTools(server) {
|
|
|
365
365
|
return txt(`Error: ${ownedProjectsError.message}`);
|
|
366
366
|
const byId = new Map();
|
|
367
367
|
for (const project of [...(directProjects ?? []), ...(ownedProjects ?? [])]) {
|
|
368
|
+
if (ctx.invitationProjectIds != null && !ctx.invitationProjectIds.includes(project.id))
|
|
369
|
+
continue;
|
|
368
370
|
byId.set(project.id, project);
|
|
369
371
|
}
|
|
370
372
|
const orgIds = Array.from(new Set(Array.from(byId.values()).map((project) => project.org_id).filter((id) => Boolean(id))));
|
|
@@ -450,6 +452,9 @@ export function registerAnalyticsTools(server) {
|
|
|
450
452
|
return j({ error: accessError.message });
|
|
451
453
|
if (!hasAccess)
|
|
452
454
|
return j({ error: 'You do not have access to this project.', code: 'PROJECT_ACCESS_DENIED' });
|
|
455
|
+
if (ctx.invitationProjectIds != null && !ctx.invitationProjectIds.includes(project_id.toLowerCase())) {
|
|
456
|
+
return j({ error: 'You do not have access to this project.', code: 'PROJECT_ACCESS_DENIED' });
|
|
457
|
+
}
|
|
453
458
|
const previousProjectId = getProjectId();
|
|
454
459
|
const { error: updateError } = await adminDb()
|
|
455
460
|
.from('mcp_oauth_tokens')
|
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.82",
|
|
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",
|