Skip to content

Commit 3687955

Browse files
authored
Merge pull request #385 from HelpCode-ai/keysersoft/anythingmcp-redesign-plan
Redesign (sidebar app shell) + cloud onboarding/conversion + KG improvements — v0.3.0
2 parents 17b1a1a + 7c8fece commit 3687955

57 files changed

Lines changed: 5223 additions & 4286 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "anythingmcp",
3-
"version": "0.2.6",
3+
"version": "0.3.0",
44
"description": "Self-hosted MCP gateway for REST, SOAP/WSDL, GraphQL and SQL — turn any API into MCP tools for Claude, ChatGPT, Gemini, Copilot and Cursor. 30+ pre-built adapters, on-prem audit log, OAuth2/RBAC. Open source (AGPL-3.0).",
55
"private": true,
66
"license": "AGPL-3.0-only",

packages/backend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@anythingmcp/backend",
3-
"version": "0.2.6",
3+
"version": "0.3.0",
44
"description": "AnythingMCP — NestJS Backend + Dynamic MCP Server",
55
"private": true,
66
"license": "AGPL-3.0-only",

packages/backend/src/auth/auth.controller.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { PrismaService } from '../common/prisma.service';
3030
import { EmailService } from '../settings/email.service';
3131
import { SiteSettingsService } from '../settings/site-settings.service';
3232
import { OrganizationsService } from '../organizations/organizations.service';
33+
import { LicenseService } from '../license/license.service';
3334
import { Roles, RolesGuard } from './roles.guard';
3435

3536
class LoginDto {
@@ -145,6 +146,7 @@ export class AuthController {
145146
private readonly configService: ConfigService,
146147
private readonly siteSettings: SiteSettingsService,
147148
private readonly organizationsService: OrganizationsService,
149+
private readonly licenseService: LicenseService,
148150
) {}
149151

150152
private getFrontendUrl(_req?: any): string {
@@ -349,6 +351,10 @@ export class AuthController {
349351
// Mark user as verified
350352
await this.usersService.update(userId, { emailVerified: true });
351353

354+
// Cloud: deterministically ensure the workspace has a trial before any
355+
// license gate evaluates (best-effort; never blocks verification).
356+
await this.ensureCloudTrial(userId);
357+
352358
return { message: 'Email verified successfully', emailVerified: true };
353359
}
354360

@@ -372,11 +378,44 @@ export class AuthController {
372378
});
373379
await this.usersService.update(record.userId, { emailVerified: true });
374380

381+
// Cloud: ensure a trial exists before the user lands back in the app.
382+
await this.ensureCloudTrial(record.userId);
383+
375384
// Redirect to frontend
376385
const frontendUrl = this.getFrontendUrl(req);
377386
return res.redirect(`${frontendUrl}/login?emailVerified=true`);
378387
}
379388

389+
/**
390+
* Cloud only: make sure a freshly-verified user's workspace has a trial
391+
* license, idempotently and best-effort. This removes the client-side race
392+
* where a failed trial activation in the login flow dropped the user onto the
393+
* "no-license" wall instead of onboarding. Never throws — verification must
394+
* succeed regardless; the login flow + LicenseWall "Start trial" CTA remain
395+
* as fallbacks.
396+
*/
397+
private async ensureCloudTrial(userId: string): Promise<void> {
398+
const isCloud = this.configService.get<string>('DEPLOYMENT_MODE') === 'cloud';
399+
if (!isCloud) return;
400+
try {
401+
const user = await this.usersService.findById(userId);
402+
if (!user?.organizationId) return;
403+
// Idempotent: skip if this org already has any license (trial or paid).
404+
const existing = await this.prisma.license.findFirst({
405+
where: { organizationId: user.organizationId },
406+
});
407+
if (existing) return;
408+
await this.licenseService.requestTrialLicense(
409+
user.email,
410+
user.name || user.email,
411+
user.organizationId,
412+
);
413+
this.logger.log(`Cloud trial activated for org ${user.organizationId} on email verification.`);
414+
} catch (err: any) {
415+
this.logger.warn(`Cloud trial activation on verify failed for user ${userId}: ${err.message}`);
416+
}
417+
}
418+
380419
@Post('resend-verification')
381420
@UseGuards(AuthGuard('jwt'))
382421
@ApiBearerAuth()

packages/backend/src/auth/auth.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { UsersModule } from '../users/users.module';
1616
import { SettingsModule } from '../settings/settings.module';
1717
import { McpServersModule } from '../mcp-servers/mcp-servers.module';
1818
import { OrganizationsModule } from '../organizations/organizations.module';
19+
import { LicenseModule } from '../license/license.module';
1920
import { getRequiredSecret } from '../common/secrets.util';
2021

2122
@Global()
@@ -25,6 +26,7 @@ import { getRequiredSecret } from '../common/secrets.util';
2526
SettingsModule,
2627
McpServersModule,
2728
OrganizationsModule,
29+
LicenseModule,
2830
PassportModule.register({ defaultStrategy: 'jwt' }),
2931
JwtModule.registerAsync({
3032
imports: [ConfigModule],

packages/backend/src/ee/cloud/onboarding-cron.service.spec.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ describe('OnboardingCronService — activation pass', () => {
55
onboardingCandidates?: any[];
66
stuckUsers?: any[];
77
sendOk?: boolean;
8+
trials?: any[];
89
}) {
910
const findMany = jest
1011
.fn()
@@ -13,7 +14,12 @@ describe('OnboardingCronService — activation pass', () => {
1314
// 2nd call: activation (stuck) cohort
1415
.mockResolvedValueOnce(overrides.stuckUsers ?? []);
1516
const update = jest.fn().mockResolvedValue({});
16-
const prisma = { user: { findMany, update } } as any;
17+
const prisma = {
18+
user: { findMany, update },
19+
// The trial-lifecycle pass runs after the activation pass; with no
20+
// trials it's a no-op. Stub just enough so it doesn't throw here.
21+
license: { findMany: jest.fn().mockResolvedValue(overrides.trials ?? []) },
22+
} as any;
1723
const email = {
1824
sendOnboardingReminderEmail: jest.fn().mockResolvedValue(true),
1925
sendActivationReminderEmail: jest

packages/backend/src/ee/cloud/onboarding-cron.service.ts

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { PrismaService } from '../../common/prisma.service';
33
import { EmailService } from '../../settings/email.service';
44

55
const HOURS = (n: number) => n * 60 * 60 * 1000;
6+
const DAYS = (n: number) => n * 24 * 60 * 60 * 1000;
67

78
/**
89
* Onboarding drip — finds users who registered, verified their email,
@@ -39,6 +40,9 @@ export class OnboardingCronService {
3940
firstReminders: number;
4041
secondReminders: number;
4142
activationReminders: number;
43+
trialWarn3: number;
44+
trialWarn1: number;
45+
trialExpired: number;
4246
skipped: number;
4347
}> {
4448
const now = Date.now();
@@ -47,6 +51,9 @@ export class OnboardingCronService {
4751
firstReminders: 0,
4852
secondReminders: 0,
4953
activationReminders: 0,
54+
trialWarn3: 0,
55+
trialWarn1: 0,
56+
trialExpired: 0,
5057
skipped: 0,
5158
};
5259

@@ -150,14 +157,102 @@ export class OnboardingCronService {
150157
}
151158

152159
await this.runActivationPass(now, out);
160+
await this.runTrialLifecyclePass(now, out);
153161

154162
this.logger.log(
155163
`Onboarding drip: examined=${out.examined} first=${out.firstReminders} ` +
156-
`second=${out.secondReminders} activation=${out.activationReminders} skipped=${out.skipped}`,
164+
`second=${out.secondReminders} activation=${out.activationReminders} ` +
165+
`trialWarn3=${out.trialWarn3} trialWarn1=${out.trialWarn1} trialExpired=${out.trialExpired} ` +
166+
`skipped=${out.skipped}`,
157167
);
158168
return out;
159169
}
160170

171+
/**
172+
* Trial lifecycle pass — value-oriented conversion nudges as a trial winds
173+
* down. For each cloud trial whose `expiresAt` is within 3 days or already
174+
* past, send the most-urgent unsent stage (expired → warn1 → warn3) to the
175+
* org's admins, with a recap of what they've built. These are account-
176+
* lifecycle (paid access ending), not marketing, so they ignore the
177+
* marketing opt-out. Idempotent via per-org OrgSettings flags
178+
* (`trial_email_{stage}`). Sends at most one stage per org per run.
179+
*/
180+
private async runTrialLifecyclePass(
181+
now: number,
182+
out: { examined: number; trialWarn3: number; trialWarn1: number; trialExpired: number; skipped: number },
183+
): Promise<void> {
184+
const trials = await this.prisma.license.findMany({
185+
where: {
186+
plan: 'trial',
187+
status: 'active',
188+
organizationId: { not: null },
189+
expiresAt: { not: null, lte: new Date(now + DAYS(3)) },
190+
},
191+
select: { organizationId: true, expiresAt: true },
192+
});
193+
194+
for (const lic of trials) {
195+
const organizationId = lic.organizationId!;
196+
const expiresAt = lic.expiresAt!.getTime();
197+
out.examined++;
198+
199+
// Ladder: most-urgent applicable stage that hasn't been sent yet.
200+
const daysLeft = Math.max(0, Math.ceil((expiresAt - now) / DAYS(1)));
201+
const stage: 'warn3' | 'warn1' | 'expired' =
202+
now >= expiresAt ? 'expired' : daysLeft <= 1 ? 'warn1' : 'warn3';
203+
const flagKey = `trial_email_${stage}`;
204+
205+
const already = await this.prisma.orgSettings.findUnique({
206+
where: { organizationId_key: { organizationId, key: flagKey } },
207+
select: { id: true },
208+
});
209+
if (already) {
210+
out.skipped++;
211+
continue;
212+
}
213+
214+
// Recipients: org admins (authoritative membership).
215+
const admins = await this.prisma.organizationMember.findMany({
216+
where: { organizationId, role: 'ADMIN' },
217+
select: { user: { select: { email: true, name: true } } },
218+
});
219+
if (admins.length === 0) {
220+
out.skipped++;
221+
continue;
222+
}
223+
224+
// Value recap (cheap counts; trial window is 7d so audit isn't pruned).
225+
const [connectors, successfulCalls] = await Promise.all([
226+
this.prisma.connector.count({ where: { organizationId } }),
227+
this.prisma.toolInvocation.count({ where: { organizationId, status: 'SUCCESS' } }),
228+
]);
229+
230+
let sentAny = false;
231+
for (const a of admins) {
232+
const ok = await this.email.sendTrialLifecycleEmail(
233+
a.user.email,
234+
a.user.name || 'there',
235+
stage,
236+
{ connectors, successfulCalls, daysLeft },
237+
);
238+
if (ok) sentAny = true;
239+
}
240+
241+
if (sentAny) {
242+
await this.prisma.orgSettings.upsert({
243+
where: { organizationId_key: { organizationId, key: flagKey } },
244+
create: { organizationId, key: flagKey, value: new Date().toISOString() },
245+
update: { value: new Date().toISOString() },
246+
});
247+
if (stage === 'expired') out.trialExpired++;
248+
else if (stage === 'warn1') out.trialWarn1++;
249+
else out.trialWarn3++;
250+
} else {
251+
out.skipped++;
252+
}
253+
}
254+
}
255+
161256
/**
162257
* Activation pass — the cohort that builds a connector but never lands a
163258
* successful tool call (the biggest single drop-off). One email only,

packages/backend/src/knowledge-graph/kg-llm.service.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ import { chatJson, resolveLlmConfig } from './llm-client';
66

77
const MAX_ENTITIES = 120; // cost cap: never send more than this to the model
88

9+
// Minimum confidence for an AI-suggested edge to be auto-applied (status 'active')
10+
// when the workspace opts in. Mirrors the skill auto-apply bar (0.9). The LLM
11+
// confidence is itself capped at 0.9, so only edges the model rates top-confidence
12+
// qualify; everything else stays a suggestion for manual review.
13+
export const EDGE_AUTO_APPLY_MIN = 0.9;
14+
915
const SYSTEM_PROMPT = `You analyze a software workspace's data entities (drawn from connected SaaS/ERP systems) and infer relationships a naive name-matching heuristic misses.
1016
1117
You receive a JSON list of entities, each with a stable "ref", its connector, its name, its input field names ("fields") and the field names its tools RETURN ("outputs").
@@ -139,6 +145,9 @@ export class KgLlmService {
139145
ctx: { idByRef: Record<string, string>; hash: string },
140146
): Promise<number> {
141147
const rels: any[] = Array.isArray(json?.relationships) ? json.relationships : [];
148+
// Read the per-workspace auto-apply switch once (no AI cost): when on,
149+
// high-confidence edges land as 'active' instead of 'suggested'.
150+
const autoApply = await this.kgStatic.getFlag(organizationId, 'kg_edge_auto_apply', false);
142151
let suggested = 0;
143152
for (const r of rels) {
144153
let src = ctx.idByRef[r?.from];
@@ -149,7 +158,7 @@ export class KgLlmService {
149158
const confidence = Math.max(0, Math.min(0.9, Number(r?.confidence) || 0.5));
150159
const note = typeof r?.reason === 'string' ? r.reason.slice(0, 200) : null;
151160
try {
152-
if (await this.upsertLlmEdge(organizationId, src, tgt, kind, confidence, note)) {
161+
if (await this.upsertLlmEdge(organizationId, src, tgt, kind, confidence, note, autoApply)) {
153162
suggested++;
154163
}
155164
} catch (e: any) {
@@ -173,7 +182,10 @@ export class KgLlmService {
173182
kind: string,
174183
confidence: number,
175184
note: string | null,
185+
autoApply = false,
176186
): Promise<boolean> {
187+
// High-confidence + opted in → apply directly; otherwise leave for review.
188+
const applied = autoApply && confidence >= EDGE_AUTO_APPLY_MIN;
177189
const existing = await this.prisma.kgEdge.findUnique({
178190
where: {
179191
organizationId_sourceNodeId_targetNodeId_kind: {
@@ -193,7 +205,7 @@ export class KgLlmService {
193205
targetNodeId,
194206
kind,
195207
source: 'LLM',
196-
status: 'suggested',
208+
status: applied ? 'active' : 'suggested',
197209
confidence,
198210
note,
199211
},
@@ -202,10 +214,16 @@ export class KgLlmService {
202214
}
203215
// Don't touch a human-curated or rejected edge; just attach the rationale.
204216
if (existing.isManual || existing.status === 'rejected') return false;
217+
// Promote a still-pending suggestion to active when auto-apply qualifies.
218+
const promote = applied && existing.status === 'suggested';
205219
await this.prisma.kgEdge.update({
206220
where: { id: existing.id },
207-
data: { note: note ?? undefined, lastSeenAt: new Date() },
221+
data: {
222+
note: note ?? undefined,
223+
lastSeenAt: new Date(),
224+
...(promote ? { status: 'active' } : {}),
225+
},
208226
});
209-
return false;
227+
return promote;
210228
}
211229
}

packages/backend/src/knowledge-graph/kg.controller.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,15 @@ export class KgController {
5353
@ApiOperation({ summary: 'Update knowledge-graph settings for this workspace' })
5454
async setSettings(
5555
@Req() req: any,
56-
@Body() body: { enabled?: boolean; llmEnabled?: boolean; captureIntent?: boolean },
56+
@Body()
57+
body: {
58+
enabled?: boolean;
59+
llmEnabled?: boolean;
60+
captureIntent?: boolean;
61+
autoExtend?: boolean;
62+
skillAutoApply?: boolean;
63+
edgeAutoApply?: boolean;
64+
},
5765
) {
5866
return this.kg.updateSettings(req.user.organizationId, body);
5967
}

packages/backend/src/knowledge-graph/kg.service.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -56,13 +56,15 @@ export class KgService {
5656
}
5757

5858
async getSettings(organizationId: string) {
59-
const [enabled, llmEnabled, captureIntent, autoExtend, skillAutoApply] = await Promise.all([
60-
this.staticSvc.isEnabled(organizationId),
61-
this.staticSvc.getFlag(organizationId, 'kg_llm_enabled', false),
62-
this.staticSvc.getFlag(organizationId, 'kg_capture_intent', false),
63-
this.staticSvc.getFlag(organizationId, 'kg_llm_auto', false),
64-
this.staticSvc.getFlag(organizationId, 'kg_skill_auto_apply', false),
65-
]);
59+
const [enabled, llmEnabled, captureIntent, autoExtend, skillAutoApply, edgeAutoApply] =
60+
await Promise.all([
61+
this.staticSvc.isEnabled(organizationId),
62+
this.staticSvc.getFlag(organizationId, 'kg_llm_enabled', false),
63+
this.staticSvc.getFlag(organizationId, 'kg_capture_intent', false),
64+
this.staticSvc.getFlag(organizationId, 'kg_llm_auto', false),
65+
this.staticSvc.getFlag(organizationId, 'kg_skill_auto_apply', false),
66+
this.staticSvc.getFlag(organizationId, 'kg_edge_auto_apply', false),
67+
]);
6668
return {
6769
enabled,
6870
llmEnabled,
@@ -72,6 +74,9 @@ export class KgService {
7274
autoExtend,
7375
// Auto-apply generated skills at/above the confidence threshold.
7476
skillAutoApply,
77+
// Auto-apply high-confidence AI-suggested graph connections (no extra AI cost —
78+
// it only changes the status of edges the enrichment pass already produced).
79+
edgeAutoApply,
7580
};
7681
}
7782

@@ -83,6 +88,7 @@ export class KgService {
8388
captureIntent?: boolean;
8489
autoExtend?: boolean;
8590
skillAutoApply?: boolean;
91+
edgeAutoApply?: boolean;
8692
},
8793
) {
8894
if (typeof body.enabled === 'boolean') {
@@ -100,6 +106,9 @@ export class KgService {
100106
if (typeof body.skillAutoApply === 'boolean') {
101107
await this.staticSvc.setFlag(organizationId, 'kg_skill_auto_apply', body.skillAutoApply);
102108
}
109+
if (typeof body.edgeAutoApply === 'boolean') {
110+
await this.staticSvc.setFlag(organizationId, 'kg_edge_auto_apply', body.edgeAutoApply);
111+
}
103112
return this.getSettings(organizationId);
104113
}
105114

0 commit comments

Comments
 (0)