@sendinel/mcp-server 1.0.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.
- package/README.md +113 -0
- package/build/anthropic-model.d.ts +5 -0
- package/build/anthropic-model.js +6 -0
- package/build/audit.d.ts +21 -0
- package/build/audit.js +38 -0
- package/build/auth.d.ts +10 -0
- package/build/auth.js +57 -0
- package/build/byod-client.d.ts +30 -0
- package/build/byod-client.js +95 -0
- package/build/crypto.d.ts +2 -0
- package/build/crypto.js +27 -0
- package/build/db.d.ts +38 -0
- package/build/db.js +70 -0
- package/build/index.d.ts +1 -0
- package/build/index.js +97 -0
- package/build/lib/action-approvals.d.ts +30 -0
- package/build/lib/action-approvals.js +154 -0
- package/build/lib/schedule.d.ts +36 -0
- package/build/lib/schedule.js +63 -0
- package/build/lib/social-engine/archetypes.d.ts +27 -0
- package/build/lib/social-engine/archetypes.js +160 -0
- package/build/lib/social-engine/index.d.ts +5 -0
- package/build/lib/social-engine/index.js +6 -0
- package/build/lib/social-engine/parse.d.ts +7 -0
- package/build/lib/social-engine/parse.js +83 -0
- package/build/lib/social-engine/platform-constraints.d.ts +27 -0
- package/build/lib/social-engine/platform-constraints.js +70 -0
- package/build/lib/social-engine/prompt-builder.d.ts +13 -0
- package/build/lib/social-engine/prompt-builder.js +80 -0
- package/build/lib/social-engine/types.d.ts +60 -0
- package/build/lib/social-engine/types.js +19 -0
- package/build/lib/url-validation.d.ts +3 -0
- package/build/lib/url-validation.js +51 -0
- package/build/lib/voice.d.ts +32 -0
- package/build/lib/voice.js +140 -0
- package/build/lib/webhook-events.d.ts +15 -0
- package/build/lib/webhook-events.js +120 -0
- package/build/plan-limits.d.ts +8 -0
- package/build/plan-limits.js +9 -0
- package/build/project.d.ts +1 -0
- package/build/project.js +9 -0
- package/build/server.d.ts +18 -0
- package/build/server.js +235 -0
- package/build/tools/ab-testing.d.ts +2 -0
- package/build/tools/ab-testing.js +204 -0
- package/build/tools/advisor.d.ts +23 -0
- package/build/tools/advisor.js +762 -0
- package/build/tools/analytics.d.ts +33 -0
- package/build/tools/analytics.js +1105 -0
- package/build/tools/approvals.d.ts +2 -0
- package/build/tools/approvals.js +32 -0
- package/build/tools/automations.d.ts +2 -0
- package/build/tools/automations.js +344 -0
- package/build/tools/campaigns.d.ts +2 -0
- package/build/tools/campaigns.js +1335 -0
- package/build/tools/compound.d.ts +2 -0
- package/build/tools/compound.js +312 -0
- package/build/tools/contacts.d.ts +2 -0
- package/build/tools/contacts.js +1483 -0
- package/build/tools/content.d.ts +2 -0
- package/build/tools/content.js +68 -0
- package/build/tools/data-proposals.d.ts +2 -0
- package/build/tools/data-proposals.js +155 -0
- package/build/tools/data.d.ts +2 -0
- package/build/tools/data.js +707 -0
- package/build/tools/delivery-ops.d.ts +2 -0
- package/build/tools/delivery-ops.js +387 -0
- package/build/tools/drafts.d.ts +2 -0
- package/build/tools/drafts.js +204 -0
- package/build/tools/forms.d.ts +2 -0
- package/build/tools/forms.js +46 -0
- package/build/tools/gdpr.d.ts +2 -0
- package/build/tools/gdpr.js +61 -0
- package/build/tools/org.d.ts +2 -0
- package/build/tools/org.js +71 -0
- package/build/tools/segments.d.ts +2 -0
- package/build/tools/segments.js +384 -0
- package/build/tools/sites.d.ts +2 -0
- package/build/tools/sites.js +182 -0
- package/build/tools/sms.d.ts +2 -0
- package/build/tools/sms.js +489 -0
- package/build/tools/social-posts.d.ts +2 -0
- package/build/tools/social-posts.js +380 -0
- package/build/tools/templates.d.ts +2 -0
- package/build/tools/templates.js +282 -0
- package/build/tools/warmup.d.ts +2 -0
- package/build/tools/warmup.js +57 -0
- package/build/tools/webhooks.d.ts +2 -0
- package/build/tools/webhooks.js +127 -0
- package/package.json +63 -0
package/README.md
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# @sendinel/mcp-server
|
|
2
|
+
|
|
3
|
+
Sendinel's published MCP server package for local stdio clients such as Claude Desktop, Claude Code, Cursor, and Codex CLI.
|
|
4
|
+
|
|
5
|
+
AI-controlled email operations control plane for campaigns, contacts, segments, templates, analytics, deliverability, and multi-provider sending across the Sendinel portfolio.
|
|
6
|
+
|
|
7
|
+
- Categories: `email`, `marketing`, `crm`, `automation`, `analytics`
|
|
8
|
+
- Official registry name: `io.github.kmdesle/sendinel`
|
|
9
|
+
- Hosted remote endpoint: `https://sendinel.ai/mcp`
|
|
10
|
+
- npm package: `@sendinel/mcp-server`
|
|
11
|
+
- Source: `https://github.com/kmdesle/sendinel-ai/tree/main/packages/mcp-server`
|
|
12
|
+
|
|
13
|
+
## Tool groups
|
|
14
|
+
|
|
15
|
+
Sendinel currently exposes 21 tool groups and about 155 tools. The full reference lives in [`docs/mcp-tools.md`](../../docs/mcp-tools.md).
|
|
16
|
+
|
|
17
|
+
| Group | Count | Purpose |
|
|
18
|
+
|-------|-------|---------|
|
|
19
|
+
| `campaigns` | 26 | Full campaign lifecycle |
|
|
20
|
+
| `contacts` | 22 | Subscriber CRUD, tags, suppression, merges, timelines |
|
|
21
|
+
| `analytics` | 9 | Stats, engagement, deliverability, portfolio reporting |
|
|
22
|
+
| `data` | 15 | Export, scoring, hygiene, migrations, import history |
|
|
23
|
+
| `org` | 15 | Team members, API keys, platform connections, plan, onboarding |
|
|
24
|
+
| `templates` | 7 | Template CRUD, AI brief generation, translation |
|
|
25
|
+
| `sms` | 7 | SMS send, consent, inbound, logs |
|
|
26
|
+
| `segments` | 6 | Audience builder and preview |
|
|
27
|
+
| `content` | 5 | Content blocks, sources, send-time optimization |
|
|
28
|
+
| `ab_testing` | 5 | A/B setup and significance checks |
|
|
29
|
+
| `drafts` | 4 | Draft create and approval flow |
|
|
30
|
+
| `delivery` | 9 | Domain and deliverability operations |
|
|
31
|
+
| `warmup` | 4 | Warmup schedule controls |
|
|
32
|
+
| `social` | 4 | Social campaigns and scheduling |
|
|
33
|
+
| `forms` | 3 | Forms and stats |
|
|
34
|
+
| `webhooks` | 4 | Webhook subscription management |
|
|
35
|
+
| `approvals` | 3 | Approval workflow and advisor hooks |
|
|
36
|
+
| `automations` | 4 | Automation triggers and previews |
|
|
37
|
+
| `sites` | 3 | Site and sender management |
|
|
38
|
+
| `gdpr` | 2 | Contact deletion and log |
|
|
39
|
+
| `history` | 3 | Chat thread history and search |
|
|
40
|
+
|
|
41
|
+
## Install / run
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
npx -y @sendinel/mcp-server@latest
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The package also exposes the legacy bin alias:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
npx -y sendinel-mcp
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Required environment
|
|
54
|
+
|
|
55
|
+
- `SENDINEL_API_KEY`
|
|
56
|
+
|
|
57
|
+
Optional server-side env when you run the package outside the main Sendinel app environment:
|
|
58
|
+
|
|
59
|
+
- `SUPABASE_URL` or `NEXT_PUBLIC_SUPABASE_URL`
|
|
60
|
+
- `SUPABASE_SERVICE_ROLE_KEY`
|
|
61
|
+
- `MCP_TRANSPORT` (`stdio` by default, `http` for HTTP/SSE mode)
|
|
62
|
+
- `MCP_SERVER_PORT` (HTTP mode only)
|
|
63
|
+
|
|
64
|
+
Stdio mode fails closed if `SENDINEL_API_KEY` is missing or invalid.
|
|
65
|
+
|
|
66
|
+
## MCP client examples
|
|
67
|
+
|
|
68
|
+
### Claude Desktop / Cursor
|
|
69
|
+
|
|
70
|
+
```json
|
|
71
|
+
{
|
|
72
|
+
"mcpServers": {
|
|
73
|
+
"sendinel": {
|
|
74
|
+
"command": "npx",
|
|
75
|
+
"args": ["-y", "@sendinel/mcp-server@latest"],
|
|
76
|
+
"env": {
|
|
77
|
+
"SENDINEL_API_KEY": "snk_your_api_key"
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Codex CLI
|
|
85
|
+
|
|
86
|
+
```toml
|
|
87
|
+
[mcp_servers.sendinel]
|
|
88
|
+
command = "npx"
|
|
89
|
+
args = ["-y", "@sendinel/mcp-server@latest"]
|
|
90
|
+
|
|
91
|
+
[mcp_servers.sendinel.env]
|
|
92
|
+
SENDINEL_API_KEY = "snk_your_api_key"
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Or add it automatically:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
codex mcp add sendinel --env SENDINEL_API_KEY=snk_your_api_key -- npx "-y" "@sendinel/mcp-server@latest"
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Transport
|
|
102
|
+
|
|
103
|
+
- `stdio` by default
|
|
104
|
+
- `http` when `MCP_TRANSPORT=http`
|
|
105
|
+
|
|
106
|
+
## Publishing
|
|
107
|
+
|
|
108
|
+
This package is intended to be published publicly to npm and listed in MCP directories:
|
|
109
|
+
|
|
110
|
+
- package name: `@sendinel/mcp-server`
|
|
111
|
+
- default launcher: `npx -y @sendinel/mcp-server@latest`
|
|
112
|
+
- official registry manifest: [`server.json`](./server.json)
|
|
113
|
+
- Smithery config: [`../../smithery.yaml`](../../smithery.yaml)
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { FALLBACK_MODEL_IDS, MODEL_REGISTRY, ROLE, getAnthropicFastModelId, getAnthropicFrontierModelId, getAnthropicModelId, getAnthropicReasoningModelId, getModelIdForRole, resolveModel, type ModelMetadata, type ModelRole, type ResolvedModel, } from '@desler/models';
|
|
2
|
+
export declare const DEFAULT_ANTHROPIC_MODEL_ID: string;
|
|
3
|
+
export declare const DEFAULT_ANTHROPIC_FAST_MODEL_ID: string;
|
|
4
|
+
export declare const DEFAULT_ANTHROPIC_OPUS_MODEL_ID: string;
|
|
5
|
+
export { getAnthropicReasoningModelId as getAnthropicOpusModelId } from '@desler/models';
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { FALLBACK_MODEL_IDS, MODEL_REGISTRY, ROLE, getAnthropicFastModelId, getAnthropicFrontierModelId, getAnthropicModelId, getAnthropicReasoningModelId, getModelIdForRole, resolveModel, } from '@desler/models';
|
|
2
|
+
import { MODEL_REGISTRY } from '@desler/models';
|
|
3
|
+
export const DEFAULT_ANTHROPIC_MODEL_ID = MODEL_REGISTRY.default.id;
|
|
4
|
+
export const DEFAULT_ANTHROPIC_FAST_MODEL_ID = MODEL_REGISTRY.fast.id;
|
|
5
|
+
export const DEFAULT_ANTHROPIC_OPUS_MODEL_ID = MODEL_REGISTRY.reasoning.id;
|
|
6
|
+
export { getAnthropicReasoningModelId as getAnthropicOpusModelId } from '@desler/models';
|
package/build/audit.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Log a consent record for GDPR compliance.
|
|
3
|
+
* Used by MCP tools that modify contact subscription state.
|
|
4
|
+
*/
|
|
5
|
+
export declare function logConsent(opts: {
|
|
6
|
+
contactId: string;
|
|
7
|
+
siteId?: string;
|
|
8
|
+
action: 'grant' | 'revoke';
|
|
9
|
+
consentType: string;
|
|
10
|
+
source: string;
|
|
11
|
+
}): Promise<void>;
|
|
12
|
+
/**
|
|
13
|
+
* Log an audit trail entry.
|
|
14
|
+
* Used by MCP tools for mutation tracking.
|
|
15
|
+
*/
|
|
16
|
+
export declare function logAudit(opts: {
|
|
17
|
+
action: string;
|
|
18
|
+
resourceType: string;
|
|
19
|
+
resourceId?: string;
|
|
20
|
+
details?: Record<string, unknown>;
|
|
21
|
+
}): Promise<void>;
|
package/build/audit.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { db } from './db.js';
|
|
2
|
+
import { getProjectId } from './project.js';
|
|
3
|
+
/**
|
|
4
|
+
* Log a consent record for GDPR compliance.
|
|
5
|
+
* Used by MCP tools that modify contact subscription state.
|
|
6
|
+
*/
|
|
7
|
+
export async function logConsent(opts) {
|
|
8
|
+
const projectId = getProjectId();
|
|
9
|
+
await db()
|
|
10
|
+
.from('consent_records')
|
|
11
|
+
.insert({
|
|
12
|
+
project_id: projectId,
|
|
13
|
+
contact_id: opts.contactId,
|
|
14
|
+
site_id: opts.siteId ?? null,
|
|
15
|
+
action: opts.action,
|
|
16
|
+
consent_type: opts.consentType,
|
|
17
|
+
source: opts.source,
|
|
18
|
+
})
|
|
19
|
+
.then(() => { }, () => { }); // fire-and-forget
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Log an audit trail entry.
|
|
23
|
+
* Used by MCP tools for mutation tracking.
|
|
24
|
+
*/
|
|
25
|
+
export async function logAudit(opts) {
|
|
26
|
+
const projectId = getProjectId();
|
|
27
|
+
await db()
|
|
28
|
+
.from('audit_log')
|
|
29
|
+
.insert({
|
|
30
|
+
project_id: projectId,
|
|
31
|
+
actor_type: 'mcp',
|
|
32
|
+
action: opts.action,
|
|
33
|
+
resource_type: opts.resourceType,
|
|
34
|
+
resource_id: opts.resourceId ?? null,
|
|
35
|
+
details: opts.details ?? {},
|
|
36
|
+
})
|
|
37
|
+
.then(() => { }, () => { }); // fire-and-forget
|
|
38
|
+
}
|
package/build/auth.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type ProjectPlan = 'free' | 'byod' | 'managed' | 'agency-plus' | 'enterprise';
|
|
2
|
+
export interface ApiKeyInfo {
|
|
3
|
+
projectId: string;
|
|
4
|
+
scopes: string[];
|
|
5
|
+
toolGroups: string[];
|
|
6
|
+
approvalMode: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function resolveToolGroupsForPlan(plan: string | null | undefined, requested: string[] | null | undefined): string[];
|
|
9
|
+
export declare function serviceKeyInfo(projectId: string): Promise<ApiKeyInfo>;
|
|
10
|
+
export declare function validateKey(key: string): Promise<ApiKeyInfo | null>;
|
package/build/auth.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { adminDb } from './db.js';
|
|
2
|
+
import { ALL_TOOL_GROUPS } from './server.js';
|
|
3
|
+
const BYOD_DEFAULT_TOOL_GROUPS = ['contacts', 'templates', 'analytics'];
|
|
4
|
+
export function resolveToolGroupsForPlan(plan, requested) {
|
|
5
|
+
if (requested && requested.length > 0) {
|
|
6
|
+
return requested.filter((group) => ALL_TOOL_GROUPS.includes(group));
|
|
7
|
+
}
|
|
8
|
+
switch (plan) {
|
|
9
|
+
case 'byod':
|
|
10
|
+
return BYOD_DEFAULT_TOOL_GROUPS.slice();
|
|
11
|
+
case 'managed':
|
|
12
|
+
case 'agency-plus':
|
|
13
|
+
case 'enterprise':
|
|
14
|
+
return ALL_TOOL_GROUPS.slice();
|
|
15
|
+
case 'free':
|
|
16
|
+
default:
|
|
17
|
+
return [];
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
async function getProjectPlan(projectId) {
|
|
21
|
+
const { data } = await adminDb()
|
|
22
|
+
.schema('email')
|
|
23
|
+
.from('projects')
|
|
24
|
+
.select('plan')
|
|
25
|
+
.eq('id', projectId)
|
|
26
|
+
.maybeSingle();
|
|
27
|
+
return data?.plan ?? 'free';
|
|
28
|
+
}
|
|
29
|
+
// First-party service auth for the MCP lane: the shared SYNCHRONEX_SERVICE_TOKEN
|
|
30
|
+
// acts on an explicitly-named project (no per-project key lookup). Tool access is
|
|
31
|
+
// resolved from the project's own plan, so the service caller gets exactly what
|
|
32
|
+
// the project is entitled to — never more. Mirrors authenticateV1's service path
|
|
33
|
+
// on the dashboard side.
|
|
34
|
+
export async function serviceKeyInfo(projectId) {
|
|
35
|
+
const plan = await getProjectPlan(projectId);
|
|
36
|
+
return {
|
|
37
|
+
projectId,
|
|
38
|
+
scopes: ['admin'],
|
|
39
|
+
toolGroups: resolveToolGroupsForPlan(plan, null),
|
|
40
|
+
approvalMode: 'standard',
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
export async function validateKey(key) {
|
|
44
|
+
const { createHash } = await import('crypto');
|
|
45
|
+
const hash = createHash('sha256').update(key).digest('hex');
|
|
46
|
+
const { data, error } = await adminDb().rpc('validate_api_key', { p_key_hash: hash });
|
|
47
|
+
if (error || !data?.length)
|
|
48
|
+
return null;
|
|
49
|
+
const row = data[0];
|
|
50
|
+
const plan = await getProjectPlan(row.project_id);
|
|
51
|
+
return {
|
|
52
|
+
projectId: row.project_id,
|
|
53
|
+
scopes: row.scopes,
|
|
54
|
+
toolGroups: resolveToolGroupsForPlan(plan, row.tool_groups),
|
|
55
|
+
approvalMode: row.approval_mode ?? 'standard',
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thrown when a project cannot be resolved to a definite client. Callers that
|
|
3
|
+
* must fail closed (the multi-tenant HTTP gateway) surface this instead of
|
|
4
|
+
* silently falling back to Sendinel's admin DB.
|
|
5
|
+
*/
|
|
6
|
+
export declare class TenantResolutionError extends Error {
|
|
7
|
+
constructor(message: string);
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Resolve the per-project Supabase client for a given project ID (lenient).
|
|
11
|
+
*
|
|
12
|
+
* - 'sendinel' projects: returns null (tools use adminDb() / Sendinel's DB).
|
|
13
|
+
* - 'byod' projects: creates and returns a client pointed at the customer's Supabase.
|
|
14
|
+
* - any resolution failure: returns null (legacy stdio fallback to Sendinel's DB).
|
|
15
|
+
*
|
|
16
|
+
* The returned client should be passed to runWithClient() (HTTP) or setProjectClient() (stdio).
|
|
17
|
+
*/
|
|
18
|
+
export declare function resolveProjectClient(projectId: string): Promise<any>;
|
|
19
|
+
/**
|
|
20
|
+
* Fail-closed variant for the multi-tenant HTTP gateway. Returns the BYOD client,
|
|
21
|
+
* or null ONLY for genuine 'sendinel'-type projects. Throws TenantResolutionError
|
|
22
|
+
* for any BYOD project that cannot be routed (missing/undecryptable credentials)
|
|
23
|
+
* so a misconfigured tenant never silently reads/writes Sendinel's admin DB.
|
|
24
|
+
*/
|
|
25
|
+
export declare function resolveProjectClientStrict(projectId: string): Promise<any>;
|
|
26
|
+
/**
|
|
27
|
+
* Convenience wrapper for the stdio transport: resolves and installs the project
|
|
28
|
+
* client as the per-process fallback (called once at startup).
|
|
29
|
+
*/
|
|
30
|
+
export declare function initProjectClient(projectId: string): Promise<void>;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { createClient } from '@supabase/supabase-js';
|
|
2
|
+
import { adminDb, setProjectClient } from './db.js';
|
|
3
|
+
import { decrypt } from './crypto.js';
|
|
4
|
+
/**
|
|
5
|
+
* Thrown when a project cannot be resolved to a definite client. Callers that
|
|
6
|
+
* must fail closed (the multi-tenant HTTP gateway) surface this instead of
|
|
7
|
+
* silently falling back to Sendinel's admin DB.
|
|
8
|
+
*/
|
|
9
|
+
export class TenantResolutionError extends Error {
|
|
10
|
+
constructor(message) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = 'TenantResolutionError';
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Core resolver. Returns:
|
|
17
|
+
* - { type: 'sendinel' } — project stores data in Sendinel's own DB (adminDb()).
|
|
18
|
+
* - { type: 'byod', client } — project routes to the customer's Supabase.
|
|
19
|
+
*
|
|
20
|
+
* Throws TenantResolutionError for projects that exist but cannot be routed
|
|
21
|
+
* definitively (not found, BYOD missing credentials, undecryptable key). Never
|
|
22
|
+
* queries the customer's DB to bootstrap itself — always uses adminDb().
|
|
23
|
+
*/
|
|
24
|
+
async function resolveConnector(projectId) {
|
|
25
|
+
const { data, error } = await adminDb()
|
|
26
|
+
.from('projects')
|
|
27
|
+
.select('connector_type, connector_supabase_url, connector_supabase_key_enc')
|
|
28
|
+
.eq('id', projectId)
|
|
29
|
+
.maybeSingle();
|
|
30
|
+
if (error)
|
|
31
|
+
throw new TenantResolutionError(`project lookup failed for ${projectId}: ${error.message}`);
|
|
32
|
+
if (!data)
|
|
33
|
+
throw new TenantResolutionError(`project ${projectId} not found`);
|
|
34
|
+
const row = data;
|
|
35
|
+
// Anything that is not an explicitly-configured BYOD connector resolves to the
|
|
36
|
+
// Sendinel admin DB — that is the intended store for 'sendinel'-type projects.
|
|
37
|
+
if (row.connector_type !== 'byod')
|
|
38
|
+
return { type: 'sendinel' };
|
|
39
|
+
if (!row.connector_supabase_url || !row.connector_supabase_key_enc) {
|
|
40
|
+
throw new TenantResolutionError(`BYOD project ${projectId} is missing connector credentials`);
|
|
41
|
+
}
|
|
42
|
+
let serviceKey;
|
|
43
|
+
try {
|
|
44
|
+
serviceKey = decrypt(row.connector_supabase_key_enc);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
throw new TenantResolutionError(`failed to decrypt connector key for BYOD project ${projectId}`);
|
|
48
|
+
}
|
|
49
|
+
const client = createClient(row.connector_supabase_url, serviceKey, {
|
|
50
|
+
auth: { autoRefreshToken: false, persistSession: false },
|
|
51
|
+
db: { schema: 'email' },
|
|
52
|
+
});
|
|
53
|
+
// Zero key material from memory
|
|
54
|
+
serviceKey = '';
|
|
55
|
+
return { type: 'byod', client };
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Resolve the per-project Supabase client for a given project ID (lenient).
|
|
59
|
+
*
|
|
60
|
+
* - 'sendinel' projects: returns null (tools use adminDb() / Sendinel's DB).
|
|
61
|
+
* - 'byod' projects: creates and returns a client pointed at the customer's Supabase.
|
|
62
|
+
* - any resolution failure: returns null (legacy stdio fallback to Sendinel's DB).
|
|
63
|
+
*
|
|
64
|
+
* The returned client should be passed to runWithClient() (HTTP) or setProjectClient() (stdio).
|
|
65
|
+
*/
|
|
66
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
67
|
+
export async function resolveProjectClient(projectId) {
|
|
68
|
+
try {
|
|
69
|
+
const resolved = await resolveConnector(projectId);
|
|
70
|
+
return resolved.type === 'byod' ? resolved.client : null;
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
process.stderr.write(`[byod-client] ${err.message} — falling back to Sendinel DB\n`);
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Fail-closed variant for the multi-tenant HTTP gateway. Returns the BYOD client,
|
|
79
|
+
* or null ONLY for genuine 'sendinel'-type projects. Throws TenantResolutionError
|
|
80
|
+
* for any BYOD project that cannot be routed (missing/undecryptable credentials)
|
|
81
|
+
* so a misconfigured tenant never silently reads/writes Sendinel's admin DB.
|
|
82
|
+
*/
|
|
83
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
84
|
+
export async function resolveProjectClientStrict(projectId) {
|
|
85
|
+
const resolved = await resolveConnector(projectId);
|
|
86
|
+
return resolved.type === 'byod' ? resolved.client : null;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Convenience wrapper for the stdio transport: resolves and installs the project
|
|
90
|
+
* client as the per-process fallback (called once at startup).
|
|
91
|
+
*/
|
|
92
|
+
export async function initProjectClient(projectId) {
|
|
93
|
+
const client = await resolveProjectClient(projectId);
|
|
94
|
+
setProjectClient(client);
|
|
95
|
+
}
|
package/build/crypto.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
|
|
2
|
+
function getKey() {
|
|
3
|
+
const hex = process.env.SENDINEL_ENCRYPTION_KEY;
|
|
4
|
+
if (!hex)
|
|
5
|
+
throw new Error('SENDINEL_ENCRYPTION_KEY env var is required');
|
|
6
|
+
const buf = Buffer.from(hex, 'hex');
|
|
7
|
+
if (buf.length !== 32)
|
|
8
|
+
throw new Error('SENDINEL_ENCRYPTION_KEY must be 32 bytes (64 hex chars)');
|
|
9
|
+
return buf;
|
|
10
|
+
}
|
|
11
|
+
export function decrypt(encrypted) {
|
|
12
|
+
const key = getKey();
|
|
13
|
+
const [ivHex, ciphertextHex, tagHex] = encrypted.split(':');
|
|
14
|
+
if (!ivHex || !ciphertextHex || !tagHex)
|
|
15
|
+
throw new Error('Invalid encrypted format');
|
|
16
|
+
const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(ivHex, 'hex'));
|
|
17
|
+
decipher.setAuthTag(Buffer.from(tagHex, 'hex'));
|
|
18
|
+
return decipher.update(ciphertextHex, 'hex', 'utf8') + decipher.final('utf8');
|
|
19
|
+
}
|
|
20
|
+
export function encrypt(plaintext) {
|
|
21
|
+
const key = getKey();
|
|
22
|
+
const iv = randomBytes(12);
|
|
23
|
+
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
|
24
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
|
25
|
+
const tag = cipher.getAuthTag();
|
|
26
|
+
return `${iv.toString('hex')}:${ciphertext.toString('hex')}:${tag.toString('hex')}`;
|
|
27
|
+
}
|
package/build/db.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import 'dotenv/config';
|
|
2
|
+
/** Returns Sendinel's admin client. Always points to Sendinel's Supabase — never overridden. */
|
|
3
|
+
export declare function adminDb(): any;
|
|
4
|
+
/**
|
|
5
|
+
* Set the per-process project client (stdio only).
|
|
6
|
+
* Called once at startup after resolving the project's connector config.
|
|
7
|
+
* Has no effect inside runWithClient() — the per-request store takes precedence.
|
|
8
|
+
*/
|
|
9
|
+
export declare function setProjectClient(client: any): void;
|
|
10
|
+
/**
|
|
11
|
+
* Run fn with a per-request project client bound to the current async context.
|
|
12
|
+
* Safe under concurrent HTTP requests — each request's tool handlers see only
|
|
13
|
+
* their own client, regardless of what other concurrent requests are doing.
|
|
14
|
+
*
|
|
15
|
+
* @param client - The resolved project client (BYOD customer's Supabase, or null for Sendinel's).
|
|
16
|
+
* @param fn - Async function to run within this client's context.
|
|
17
|
+
*/
|
|
18
|
+
export declare function runWithClient<T>(client: any, fn: () => Promise<T>, request?: {
|
|
19
|
+
projectId?: string;
|
|
20
|
+
approvalMode?: string;
|
|
21
|
+
}): Promise<T>;
|
|
22
|
+
/**
|
|
23
|
+
* Request-scoped identity set via runWithClient() (HTTP transport).
|
|
24
|
+
* Undefined under stdio, where the process env is the single-project source of truth.
|
|
25
|
+
*/
|
|
26
|
+
export declare function requestContext(): {
|
|
27
|
+
projectId?: string;
|
|
28
|
+
approvalMode?: string;
|
|
29
|
+
} | undefined;
|
|
30
|
+
/**
|
|
31
|
+
* Returns the project client appropriate for the current execution context:
|
|
32
|
+
* 1. Per-request store (HTTP): set via runWithClient() — safe under concurrency.
|
|
33
|
+
* 2. Per-process fallback (stdio): set via setProjectClient() at startup.
|
|
34
|
+
* 3. Sendinel admin DB: if no override is set (sendinel-type project).
|
|
35
|
+
*
|
|
36
|
+
* Use this in all tool handlers for customer data reads/writes.
|
|
37
|
+
*/
|
|
38
|
+
export declare function db(): any;
|
package/build/db.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import 'dotenv/config';
|
|
2
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
3
|
+
import { createClient } from '@supabase/supabase-js';
|
|
4
|
+
// Sendinel's own Supabase (always used for infrastructure: API key validation, plan limits)
|
|
5
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
6
|
+
let _adminClient = null;
|
|
7
|
+
const _requestStore = new AsyncLocalStorage();
|
|
8
|
+
// Per-process fallback (stdio transport — single-project, set once at startup)
|
|
9
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
10
|
+
let _processClient = null;
|
|
11
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
12
|
+
function buildAdminClient() {
|
|
13
|
+
const url = process.env.SUPABASE_URL;
|
|
14
|
+
const key = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
|
15
|
+
if (!url || !key)
|
|
16
|
+
throw new Error('SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY must be set');
|
|
17
|
+
return createClient(url, key, {
|
|
18
|
+
auth: { autoRefreshToken: false, persistSession: false },
|
|
19
|
+
db: { schema: 'email' },
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
/** Returns Sendinel's admin client. Always points to Sendinel's Supabase — never overridden. */
|
|
23
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
24
|
+
export function adminDb() {
|
|
25
|
+
if (!_adminClient)
|
|
26
|
+
_adminClient = buildAdminClient();
|
|
27
|
+
return _adminClient;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Set the per-process project client (stdio only).
|
|
31
|
+
* Called once at startup after resolving the project's connector config.
|
|
32
|
+
* Has no effect inside runWithClient() — the per-request store takes precedence.
|
|
33
|
+
*/
|
|
34
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
35
|
+
export function setProjectClient(client) {
|
|
36
|
+
_processClient = client;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Run fn with a per-request project client bound to the current async context.
|
|
40
|
+
* Safe under concurrent HTTP requests — each request's tool handlers see only
|
|
41
|
+
* their own client, regardless of what other concurrent requests are doing.
|
|
42
|
+
*
|
|
43
|
+
* @param client - The resolved project client (BYOD customer's Supabase, or null for Sendinel's).
|
|
44
|
+
* @param fn - Async function to run within this client's context.
|
|
45
|
+
*/
|
|
46
|
+
export async function runWithClient(
|
|
47
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
48
|
+
client, fn, request) {
|
|
49
|
+
return _requestStore.run({ client, ...request }, fn);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Request-scoped identity set via runWithClient() (HTTP transport).
|
|
53
|
+
* Undefined under stdio, where the process env is the single-project source of truth.
|
|
54
|
+
*/
|
|
55
|
+
export function requestContext() {
|
|
56
|
+
const ctx = _requestStore.getStore();
|
|
57
|
+
return ctx ? { projectId: ctx.projectId, approvalMode: ctx.approvalMode } : undefined;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Returns the project client appropriate for the current execution context:
|
|
61
|
+
* 1. Per-request store (HTTP): set via runWithClient() — safe under concurrency.
|
|
62
|
+
* 2. Per-process fallback (stdio): set via setProjectClient() at startup.
|
|
63
|
+
* 3. Sendinel admin DB: if no override is set (sendinel-type project).
|
|
64
|
+
*
|
|
65
|
+
* Use this in all tool handlers for customer data reads/writes.
|
|
66
|
+
*/
|
|
67
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
68
|
+
export function db() {
|
|
69
|
+
return _requestStore.getStore()?.client ?? _processClient ?? adminDb();
|
|
70
|
+
}
|
package/build/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/build/index.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { config } from 'dotenv';
|
|
2
|
+
import { resolve, dirname } from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
5
|
+
config({ path: resolve(__dirname, '../.env') });
|
|
6
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
7
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
8
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
9
|
+
import express from 'express';
|
|
10
|
+
import { registerAllTools } from './server.js';
|
|
11
|
+
import { runWithClient } from './db.js';
|
|
12
|
+
import { initProjectClient, resolveProjectClient } from './byod-client.js';
|
|
13
|
+
import { serviceKeyInfo, validateKey } from './auth.js';
|
|
14
|
+
const transport = process.env.MCP_TRANSPORT ?? 'stdio';
|
|
15
|
+
async function runStdio() {
|
|
16
|
+
const server = new McpServer({ name: 'sendinel', version: '0.1.0' });
|
|
17
|
+
// For stdio, validate SENDINEL_API_KEY and fail closed if it is absent.
|
|
18
|
+
const apiKey = process.env.SENDINEL_API_KEY;
|
|
19
|
+
let toolGroups;
|
|
20
|
+
let scopes;
|
|
21
|
+
if (!apiKey) {
|
|
22
|
+
process.stderr.write('SENDINEL_API_KEY is required for stdio transport\n');
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
const info = await validateKey(apiKey);
|
|
26
|
+
if (!info) {
|
|
27
|
+
process.stderr.write('Invalid SENDINEL_API_KEY\n');
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
toolGroups = info.toolGroups;
|
|
31
|
+
scopes = info.scopes?.length ? info.scopes : undefined;
|
|
32
|
+
process.env.SENDINEL_PROJECT_ID = info.projectId;
|
|
33
|
+
// Initialize per-project client (BYOD: routes db() to customer's Supabase)
|
|
34
|
+
const projectId = process.env.SENDINEL_PROJECT_ID;
|
|
35
|
+
if (projectId) {
|
|
36
|
+
await initProjectClient(projectId);
|
|
37
|
+
}
|
|
38
|
+
registerAllTools(server, { toolGroups, scopes });
|
|
39
|
+
const t = new StdioServerTransport();
|
|
40
|
+
await server.connect(t);
|
|
41
|
+
process.stderr.write('Sendinel MCP server running (stdio)\n');
|
|
42
|
+
}
|
|
43
|
+
async function runHttp() {
|
|
44
|
+
const port = parseInt(process.env.MCP_SERVER_PORT ?? '3001', 10);
|
|
45
|
+
const app = express();
|
|
46
|
+
app.use(express.json());
|
|
47
|
+
app.use('/mcp', async (req, res, next) => {
|
|
48
|
+
const authHeader = req.headers.authorization;
|
|
49
|
+
if (!authHeader?.startsWith('Bearer ')) {
|
|
50
|
+
res.status(401).json({ error: 'Missing API key. Use Authorization: Bearer snk_...' });
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const key = authHeader.slice(7);
|
|
54
|
+
let info = await validateKey(key);
|
|
55
|
+
if (!info) {
|
|
56
|
+
res.status(401).json({ error: 'Invalid or revoked API key' });
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
// First-party service key (service:provision scope) may target any project
|
|
60
|
+
// via X-Sendinel-Project-Id, so Synchronex drives the bundled MCP lane
|
|
61
|
+
// without storing a per-project key. Other keys ignore the header.
|
|
62
|
+
const overrideProjectId = req.headers['x-sendinel-project-id']?.trim();
|
|
63
|
+
if (overrideProjectId && info.scopes?.includes('service:provision')) {
|
|
64
|
+
info = await serviceKeyInfo(overrideProjectId);
|
|
65
|
+
}
|
|
66
|
+
req._apiKeyInfo = info;
|
|
67
|
+
next();
|
|
68
|
+
});
|
|
69
|
+
app.post('/mcp', async (req, res) => {
|
|
70
|
+
const info = req._apiKeyInfo;
|
|
71
|
+
// Resolve the per-project client (null for sendinel-type projects).
|
|
72
|
+
// runWithClient() binds it — plus projectId/approvalMode — to this request's
|
|
73
|
+
// AsyncLocalStorage context. Nothing request-scoped may be written to
|
|
74
|
+
// process.env here: it is process-global and leaks across concurrent tenants.
|
|
75
|
+
const projectClient = await resolveProjectClient(info.projectId);
|
|
76
|
+
await runWithClient(projectClient, async () => {
|
|
77
|
+
const server = new McpServer({ name: 'sendinel', version: '0.1.0' });
|
|
78
|
+
registerAllTools(server, {
|
|
79
|
+
scopes: info.scopes,
|
|
80
|
+
toolGroups: info.toolGroups,
|
|
81
|
+
});
|
|
82
|
+
const t = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
|
83
|
+
await server.connect(t);
|
|
84
|
+
await t.handleRequest(req, res, req.body);
|
|
85
|
+
}, { projectId: info.projectId, approvalMode: info.approvalMode });
|
|
86
|
+
});
|
|
87
|
+
app.get('/health', (_req, res) => res.json({ ok: true }));
|
|
88
|
+
app.listen(port, () => {
|
|
89
|
+
console.log(`Sendinel MCP server running on http://localhost:${port}/mcp`);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
if (transport === 'http') {
|
|
93
|
+
runHttp().catch((err) => { console.error(err); process.exit(1); });
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
runStdio().catch((err) => { console.error(err); process.exit(1); });
|
|
97
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export type ApprovalTier = 'autonomous' | 'gated' | 'protected';
|
|
2
|
+
export declare function getApprovalTier(action: string, params?: Record<string, unknown>): ApprovalTier;
|
|
3
|
+
/** Build a human-readable summary of the action for the approval screen */
|
|
4
|
+
export declare function buildApprovalSummary(action: string, params: Record<string, unknown>): string;
|
|
5
|
+
export interface ApprovalResult {
|
|
6
|
+
requires_approval: true;
|
|
7
|
+
approval_id: string;
|
|
8
|
+
tier: ApprovalTier;
|
|
9
|
+
action: string;
|
|
10
|
+
summary: string;
|
|
11
|
+
expires_at: string;
|
|
12
|
+
approve_via: string;
|
|
13
|
+
message: string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Request human approval for an action. Creates a pending approval token
|
|
17
|
+
* and returns instructions for the agent to wait.
|
|
18
|
+
*/
|
|
19
|
+
export declare function requestApproval(action: string, params: Record<string, unknown>, apiKeyId?: string): Promise<ApprovalResult>;
|
|
20
|
+
/**
|
|
21
|
+
* Check if an approval token has been approved.
|
|
22
|
+
* Returns the approval if approved, null if still pending, throws if rejected/expired.
|
|
23
|
+
*/
|
|
24
|
+
export declare function checkApproval(approvalId: string, options?: {
|
|
25
|
+
consume?: boolean;
|
|
26
|
+
}): Promise<{
|
|
27
|
+
approved: boolean;
|
|
28
|
+
status: string;
|
|
29
|
+
decided_by?: string;
|
|
30
|
+
}>;
|