frinus-mcp 3.6.3 → 3.7.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/dist/client/cp-client.d.ts +7 -1
- package/dist/client/cp-client.js +26 -0
- package/dist/index.js +50 -19
- package/dist/notifications.d.ts +30 -0
- package/dist/notifications.js +86 -0
- package/dist/tools/definitions.js +57 -8
- package/dist/tools/handlers.js +148 -20
- package/dist/types/index.d.ts +24 -0
- package/manifest.json +1 -1
- package/package.json +2 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CpClientInterface } from "../types/index.js";
|
|
1
|
+
import type { CpClientInterface, NotificationInbox } from "../types/index.js";
|
|
2
2
|
export declare class CpClient implements CpClientInterface {
|
|
3
3
|
private client;
|
|
4
4
|
constructor(baseURL: string, apiKey: string);
|
|
@@ -13,6 +13,12 @@ export declare class CpClient implements CpClientInterface {
|
|
|
13
13
|
description?: string;
|
|
14
14
|
}): Promise<any>;
|
|
15
15
|
deleteUniverse(orgId: string, universeId: string): Promise<void>;
|
|
16
|
+
getNotificationInbox(opts?: {
|
|
17
|
+
channel?: string;
|
|
18
|
+
limit?: number;
|
|
19
|
+
markDelivered?: boolean;
|
|
20
|
+
}): Promise<NotificationInbox>;
|
|
21
|
+
ackNotification(id: string): Promise<void>;
|
|
16
22
|
storeCredential(integrationRef: string, data: Record<string, unknown>, metadata?: Record<string, unknown>): Promise<any>;
|
|
17
23
|
getCredential(integrationRef: string): Promise<any>;
|
|
18
24
|
listCredentials(): Promise<any>;
|
package/dist/client/cp-client.js
CHANGED
|
@@ -4,6 +4,13 @@
|
|
|
4
4
|
import axios from "axios";
|
|
5
5
|
import { getResolvedTenantOrgId } from "./memory-client.js";
|
|
6
6
|
import { getResolvedClient } from "./client-info.js";
|
|
7
|
+
/**
|
|
8
|
+
* Short timeout (ms) for the platform-notification calls. These are best-effort
|
|
9
|
+
* side channels that must never slow down bootstrap/heartbeat or hold the agent
|
|
10
|
+
* hostage on a slow network, so they use a much tighter deadline than the 30s
|
|
11
|
+
* default of the rest of the Control Plane client.
|
|
12
|
+
*/
|
|
13
|
+
const NOTIFICATION_TIMEOUT_MS = 4000;
|
|
7
14
|
export class CpClient {
|
|
8
15
|
client;
|
|
9
16
|
constructor(baseURL, apiKey) {
|
|
@@ -37,6 +44,25 @@ export class CpClient {
|
|
|
37
44
|
await this.client.delete(`/api/v1/orgs/${orgId}/universes/${universeId}`);
|
|
38
45
|
}
|
|
39
46
|
// =========================================================================
|
|
47
|
+
// Platform Notifications (short timeout, best-effort)
|
|
48
|
+
// =========================================================================
|
|
49
|
+
async getNotificationInbox(opts = {}) {
|
|
50
|
+
const response = await this.client.get(`/api/v1/notifications/inbox`, {
|
|
51
|
+
params: {
|
|
52
|
+
channel: opts.channel ?? "mcp",
|
|
53
|
+
limit: opts.limit ?? 3,
|
|
54
|
+
mark_delivered: opts.markDelivered ?? false,
|
|
55
|
+
},
|
|
56
|
+
timeout: NOTIFICATION_TIMEOUT_MS,
|
|
57
|
+
});
|
|
58
|
+
return response.data;
|
|
59
|
+
}
|
|
60
|
+
async ackNotification(id) {
|
|
61
|
+
await this.client.post(`/api/v1/notifications/${id}/ack`, undefined, {
|
|
62
|
+
timeout: NOTIFICATION_TIMEOUT_MS,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
// =========================================================================
|
|
40
66
|
// Credential Vault
|
|
41
67
|
// =========================================================================
|
|
42
68
|
async storeCredential(integrationRef, data, metadata) {
|
package/dist/index.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* tools/handlers.ts - Tool handlers (dispatch map)
|
|
12
12
|
* types/index.ts - Shared interfaces and types
|
|
13
13
|
*/
|
|
14
|
+
import net from "node:net";
|
|
14
15
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
15
16
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
16
17
|
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
@@ -26,6 +27,15 @@ import { dispatchTool } from "./tools/handlers.js";
|
|
|
26
27
|
const MEMORY_SERVICE_URL = process.env.MEMORY_SERVICE_URL || "https://frinus-memory.rdxsec.com.br";
|
|
27
28
|
const CP_URL = process.env.FRINUS_CP_URL || "https://frinus-api.rdxsec.com.br";
|
|
28
29
|
const AGENT_SERVICE_URL = process.env.AGENT_SERVICE_URL || "https://frinus-agents.rdxsec.com.br";
|
|
30
|
+
// Give Happy Eyeballs (RFC 8305) a realistic deadline before giving up on an
|
|
31
|
+
// address family. Node's default auto-select-family attempt timeout is 250ms,
|
|
32
|
+
// which on a slow network (2026-10-27 incident) made the API-key validation
|
|
33
|
+
// fail with "Failed to validate API key: AggregateError" before the TCP
|
|
34
|
+
// handshake could complete. 5s per attempt matches how long a cold mobile/VPN
|
|
35
|
+
// link realistically needs. Guarded with typeof for older Node runtimes.
|
|
36
|
+
if (typeof net.setDefaultAutoSelectFamilyAttemptTimeout === "function") {
|
|
37
|
+
net.setDefaultAutoSelectFamilyAttemptTimeout(5000);
|
|
38
|
+
}
|
|
29
39
|
// Main server
|
|
30
40
|
async function main() {
|
|
31
41
|
// Require API key (FRINUS_API_KEY preferred, FRINUS_MEMORY_API_KEY for backward compat)
|
|
@@ -40,31 +50,52 @@ async function main() {
|
|
|
40
50
|
const cpClient = new CpClient(CP_URL, apiKey);
|
|
41
51
|
const capture = new InteractionCapture(memoryClient);
|
|
42
52
|
console.error(`[InteractionCapture] Session: ${capture.getSessionId()}`);
|
|
43
|
-
// Validate API key and resolve tenant identity at startup
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
console.error(`[FATAL] API key rejected (HTTP ${status}). Check FRINUS_API_KEY.`);
|
|
53
|
+
// Validate API key and resolve tenant identity at startup.
|
|
54
|
+
//
|
|
55
|
+
// Retry ONLY transient network failures (no HTTP response: DNS, connection
|
|
56
|
+
// refused/reset, timeout, AggregateError from Happy Eyeballs). A 401/403 is a
|
|
57
|
+
// real rejection and must fail fast — retrying a bad key is pointless. Backoff
|
|
58
|
+
// 1s then 3s between the 3 attempts. This, together with the auto-select-family
|
|
59
|
+
// timeout above, is the fix for the 2026-10-27 "Failed to validate API key:
|
|
60
|
+
// AggregateError" crashes on slow links.
|
|
61
|
+
const MAX_AUTH_ATTEMPTS = 3;
|
|
62
|
+
const AUTH_BACKOFFS_MS = [1000, 3000];
|
|
63
|
+
let me;
|
|
64
|
+
for (let attempt = 1;; attempt++) {
|
|
65
|
+
try {
|
|
66
|
+
me = await memoryClient.getAuthMe();
|
|
67
|
+
break;
|
|
59
68
|
}
|
|
60
|
-
|
|
69
|
+
catch (err) {
|
|
70
|
+
const status = err?.response?.status;
|
|
71
|
+
if (status === 401 || status === 403) {
|
|
72
|
+
console.error(`[FATAL] API key rejected (HTTP ${status}). Check FRINUS_API_KEY.`);
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
// No HTTP response == network/transport error -> retryable.
|
|
76
|
+
const isNetworkError = status === undefined;
|
|
77
|
+
if (isNetworkError && attempt < MAX_AUTH_ATTEMPTS) {
|
|
78
|
+
const waitMs = AUTH_BACKOFFS_MS[attempt - 1];
|
|
79
|
+
console.error(`[Auth] Network error validating API key (attempt ${attempt}/${MAX_AUTH_ATTEMPTS}): ` +
|
|
80
|
+
`${err?.message || err}. Retrying in ${waitMs}ms...`);
|
|
81
|
+
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
61
84
|
console.error(`[FATAL] Failed to validate API key: ${err?.message || err}`);
|
|
85
|
+
process.exit(1);
|
|
62
86
|
}
|
|
63
|
-
process.exit(1);
|
|
64
87
|
}
|
|
88
|
+
setResolvedUserEmail(me.email || me.user?.email || null);
|
|
89
|
+
setResolvedUserId(me.id || me.user?.id || me.user_id || null);
|
|
90
|
+
// Resolve tenant org ID from /auth/me response (env var takes precedence)
|
|
91
|
+
const meOrgId = me.organization_id || me.org_id || me.user?.organization_id || null;
|
|
92
|
+
if (!getResolvedTenantOrgId() && meOrgId) {
|
|
93
|
+
setResolvedTenantOrgId(meOrgId);
|
|
94
|
+
}
|
|
95
|
+
console.error(`[Auth] API key validated. User: ${getResolvedUserEmail()}, Tenant: ${getResolvedTenantOrgId() || 'none'}`);
|
|
65
96
|
const server = new Server({
|
|
66
97
|
name: "frinus",
|
|
67
|
-
version: "3.
|
|
98
|
+
version: "3.7.0",
|
|
68
99
|
}, {
|
|
69
100
|
capabilities: {
|
|
70
101
|
tools: {},
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Platform notifications delivered through the MCP channel.
|
|
3
|
+
*
|
|
4
|
+
* Clients rarely come back to the web dashboard after the initial integration,
|
|
5
|
+
* so the MCP session itself becomes the way to reach them. This module fetches
|
|
6
|
+
* the Control Plane inbox and renders a compact markdown block that is appended
|
|
7
|
+
* to `agent_bootstrap` (once, forced) and `heartbeat_tick` (throttled).
|
|
8
|
+
*
|
|
9
|
+
* Design rules:
|
|
10
|
+
* - NEVER throws. A 404, timeout or any network error yields '' plus a stderr
|
|
11
|
+
* log. The agent's real work must never be blocked by the news channel.
|
|
12
|
+
* - In-process state, per MCP process (= per session): which notification ids
|
|
13
|
+
* were already shown, and when we last hit the inbox. A long-lived process
|
|
14
|
+
* therefore shows each item at most once and polls at most once per window.
|
|
15
|
+
* - Env toggles: FRINUS_NOTIFICATIONS=off disables everything;
|
|
16
|
+
* FRINUS_NOTIFICATIONS_INTERVAL_MIN overrides the throttle window (default 60).
|
|
17
|
+
*/
|
|
18
|
+
import type { CpClientInterface } from "./types/index.js";
|
|
19
|
+
/** Reset module state. Test-only; not part of the runtime surface. */
|
|
20
|
+
export declare function __resetNotificationsState(): void;
|
|
21
|
+
export interface FetchBlockOptions {
|
|
22
|
+
/** Bypass the throttle window (used by agent_bootstrap). */
|
|
23
|
+
force?: boolean;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Fetch the inbox and render the "Novidades do Frinus" block, or '' when there
|
|
27
|
+
* is nothing new to show (disabled, throttled, empty, already-shown, or any
|
|
28
|
+
* failure). Guaranteed not to throw.
|
|
29
|
+
*/
|
|
30
|
+
export declare function fetchNotificationsBlock(cpClient: CpClientInterface, opts?: FetchBlockOptions): Promise<string>;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/** Max characters of a notification body rendered inline. */
|
|
2
|
+
const BODY_LIMIT = 600;
|
|
3
|
+
/** Default throttle window for non-forced checks (minutes). */
|
|
4
|
+
const DEFAULT_INTERVAL_MIN = 60;
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// In-process session state
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
/** Notification ids already shown in this session (dedupe across bootstrap + ticks). */
|
|
9
|
+
const shownIds = new Set();
|
|
10
|
+
/** Epoch ms of the last inbox check (forced or throttled). 0 = never. */
|
|
11
|
+
let lastCheckAt = 0;
|
|
12
|
+
/** Reset module state. Test-only; not part of the runtime surface. */
|
|
13
|
+
export function __resetNotificationsState() {
|
|
14
|
+
shownIds.clear();
|
|
15
|
+
lastCheckAt = 0;
|
|
16
|
+
}
|
|
17
|
+
function isDisabled() {
|
|
18
|
+
return (process.env.FRINUS_NOTIFICATIONS || "").trim().toLowerCase() === "off";
|
|
19
|
+
}
|
|
20
|
+
function throttleMs() {
|
|
21
|
+
const raw = process.env.FRINUS_NOTIFICATIONS_INTERVAL_MIN;
|
|
22
|
+
const parsed = raw !== undefined ? Number.parseInt(raw, 10) : NaN;
|
|
23
|
+
const minutes = Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_INTERVAL_MIN;
|
|
24
|
+
return minutes * 60_000;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Fetch the inbox and render the "Novidades do Frinus" block, or '' when there
|
|
28
|
+
* is nothing new to show (disabled, throttled, empty, already-shown, or any
|
|
29
|
+
* failure). Guaranteed not to throw.
|
|
30
|
+
*/
|
|
31
|
+
export async function fetchNotificationsBlock(cpClient, opts = {}) {
|
|
32
|
+
if (isDisabled())
|
|
33
|
+
return "";
|
|
34
|
+
const now = Date.now();
|
|
35
|
+
if (!opts.force && now - lastCheckAt < throttleMs()) {
|
|
36
|
+
return "";
|
|
37
|
+
}
|
|
38
|
+
lastCheckAt = now;
|
|
39
|
+
let inbox;
|
|
40
|
+
try {
|
|
41
|
+
inbox = await cpClient.getNotificationInbox({
|
|
42
|
+
channel: "mcp",
|
|
43
|
+
limit: 3,
|
|
44
|
+
markDelivered: true,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
// 404 (CP not yet deployed), timeout, DNS, connection refused — all benign
|
|
49
|
+
// here. Swallow and keep working; log to stderr for operators.
|
|
50
|
+
console.error(`[Notifications] inbox fetch failed: ${err?.message || err}`);
|
|
51
|
+
return "";
|
|
52
|
+
}
|
|
53
|
+
const fresh = (inbox?.items || []).filter((it) => !!it && typeof it.id === "string" && !shownIds.has(it.id));
|
|
54
|
+
if (fresh.length === 0)
|
|
55
|
+
return "";
|
|
56
|
+
for (const it of fresh)
|
|
57
|
+
shownIds.add(it.id);
|
|
58
|
+
return renderBlock(fresh);
|
|
59
|
+
}
|
|
60
|
+
/** Render the markdown block the agent relays to the user. */
|
|
61
|
+
function renderBlock(items) {
|
|
62
|
+
let out = `## 📣 Novidades do Frinus\n\n`;
|
|
63
|
+
for (const it of items) {
|
|
64
|
+
const sev = it.severity ? ` _(${it.severity})_` : "";
|
|
65
|
+
out += `### ${it.title}${sev}\n`;
|
|
66
|
+
let body = (it.body || "").trim();
|
|
67
|
+
if (body.length > BODY_LIMIT) {
|
|
68
|
+
body = body.slice(0, BODY_LIMIT).trimEnd() + "…";
|
|
69
|
+
}
|
|
70
|
+
if (body)
|
|
71
|
+
out += `${body}\n`;
|
|
72
|
+
if (it.cta_label && it.cta_url) {
|
|
73
|
+
out += `\n[${it.cta_label}](${it.cta_url})\n`;
|
|
74
|
+
}
|
|
75
|
+
else if (it.cta_url) {
|
|
76
|
+
out += `\n${it.cta_url}\n`;
|
|
77
|
+
}
|
|
78
|
+
out += `\n_id: ${it.id}_\n\n`;
|
|
79
|
+
}
|
|
80
|
+
out += `---\n`;
|
|
81
|
+
out +=
|
|
82
|
+
`Avise o usuário destas novidades de forma breve (1–3 linhas cada) no ` +
|
|
83
|
+
`início da sua próxima resposta, sem interromper a tarefa. Quando ele ` +
|
|
84
|
+
`confirmar ciência ou pedir para não ver mais, chame notification_ack(id).\n`;
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
@@ -9,16 +9,19 @@ export const TOOLS = [
|
|
|
9
9
|
Use this to record important information, learnings, decisions, or procedures
|
|
10
10
|
that should be remembered for future reference.
|
|
11
11
|
|
|
12
|
-
The backend classifies the memory by content at write time and
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
The backend classifies the memory by content at write time and proposes which
|
|
13
|
+
universe/context node it belongs to. When the tenant has auto-classification
|
|
14
|
+
enabled and the proposal's confidence meets the tenant threshold (the platform
|
|
15
|
+
target is 0.60), the backend ANCHORS the memory on that node automatically — it
|
|
16
|
+
is moved without review. Below the threshold (or with auto-classification off)
|
|
17
|
+
the proposal stays a PENDING suggestion: review it with
|
|
18
|
+
memory_classification_pending and apply with memory_classification_approve.
|
|
16
19
|
|
|
17
20
|
Passing universe_id or context_id anchors the memory THERE directly (status
|
|
18
21
|
"manual"): auto-classification is skipped entirely, so no suggestion is returned
|
|
19
22
|
and nothing may later move it. The response always reports the destination the
|
|
20
|
-
memory actually landed in — a suggestion
|
|
21
|
-
and
|
|
23
|
+
memory actually landed in — a suggestion that is still pending is labelled as
|
|
24
|
+
such and has NOT been applied.
|
|
22
25
|
|
|
23
26
|
Memory types:
|
|
24
27
|
- episodic: Specific experiences and events (what happened)
|
|
@@ -678,8 +681,15 @@ Useful for maintaining consistency in the knowledge base.`,
|
|
|
678
681
|
inputSchema: {
|
|
679
682
|
type: "object",
|
|
680
683
|
properties: {
|
|
681
|
-
|
|
682
|
-
|
|
684
|
+
// NOTE: the backend (POST /consolidation/detect-conflicts →
|
|
685
|
+
// ConflictDetectionRequest) does NOT accept similarity_threshold for
|
|
686
|
+
// conflict detection — conflict pairs are found by the forgetting
|
|
687
|
+
// manager's own analysis, and the field is silently ignored (the
|
|
688
|
+
// request model inherits _IgnoreExtra). It is kept here only for
|
|
689
|
+
// forward compatibility; it currently has no effect. Redundant
|
|
690
|
+
// detection (consolidation_detect_redundant) DOES honour it.
|
|
691
|
+
similarity_threshold: { type: "number", description: "Currently IGNORED by the backend for conflict detection (kept for forward compatibility). Use consolidation_detect_redundant to tune a similarity threshold." },
|
|
692
|
+
limit: { type: "integer", description: "Maximum memories to analyze (default: 50)" },
|
|
683
693
|
},
|
|
684
694
|
required: [],
|
|
685
695
|
},
|
|
@@ -1845,4 +1855,43 @@ granted it and when. No secret data.`,
|
|
|
1845
1855
|
required: ["ref"],
|
|
1846
1856
|
},
|
|
1847
1857
|
},
|
|
1858
|
+
// ==========================================================================
|
|
1859
|
+
// Platform Notifications (2)
|
|
1860
|
+
// ==========================================================================
|
|
1861
|
+
{
|
|
1862
|
+
name: "notifications_list",
|
|
1863
|
+
description: `List pending Frinus platform notifications for the current user.
|
|
1864
|
+
|
|
1865
|
+
These are platform-authored announcements: new features, maintenance windows,
|
|
1866
|
+
tips and alerts. This is a READ-ONLY peek — it does not mark anything as
|
|
1867
|
+
delivered or acknowledged, so items stay in the inbox until the user sees them.
|
|
1868
|
+
|
|
1869
|
+
Each item includes an \`id\` you can pass to notification_ack once the user has
|
|
1870
|
+
read it. Use this when the user asks "what's new" or to re-check news that was
|
|
1871
|
+
surfaced earlier in the session.`,
|
|
1872
|
+
inputSchema: {
|
|
1873
|
+
type: "object",
|
|
1874
|
+
properties: {},
|
|
1875
|
+
required: [],
|
|
1876
|
+
},
|
|
1877
|
+
},
|
|
1878
|
+
{
|
|
1879
|
+
name: "notification_ack",
|
|
1880
|
+
description: `Acknowledge a Frinus platform notification by id.
|
|
1881
|
+
|
|
1882
|
+
Call this once the user confirms they have seen the news, or asks not to see it
|
|
1883
|
+
again. The notification then disappears from the inbox and will not be shown
|
|
1884
|
+
again (in this or future sessions). The id comes from the "📣 Novidades do
|
|
1885
|
+
Frinus" block or from notifications_list.`,
|
|
1886
|
+
inputSchema: {
|
|
1887
|
+
type: "object",
|
|
1888
|
+
properties: {
|
|
1889
|
+
id: {
|
|
1890
|
+
type: "string",
|
|
1891
|
+
description: "UUID of the notification to acknowledge (required)",
|
|
1892
|
+
},
|
|
1893
|
+
},
|
|
1894
|
+
required: ["id"],
|
|
1895
|
+
},
|
|
1896
|
+
},
|
|
1848
1897
|
];
|
package/dist/tools/handlers.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import axios from "axios";
|
|
8
8
|
import { spawn } from "node:child_process";
|
|
9
9
|
import { getResolvedTenantOrgId } from "../client/memory-client.js";
|
|
10
|
+
import { fetchNotificationsBlock } from "../notifications.js";
|
|
10
11
|
// ---------------------------------------------------------------------------
|
|
11
12
|
// Credential delivery — server-side broker, never by value
|
|
12
13
|
// ---------------------------------------------------------------------------
|
|
@@ -365,14 +366,17 @@ const handlers = {
|
|
|
365
366
|
const userId = args.user_id || resolvedUserId || undefined;
|
|
366
367
|
const meta = args.metadata || {};
|
|
367
368
|
const universeId = args.universe_id || meta.universe_id || undefined;
|
|
368
|
-
//
|
|
369
|
-
//
|
|
370
|
-
//
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
//
|
|
375
|
-
//
|
|
369
|
+
// Write-time classification: the backend classifies the memory by content
|
|
370
|
+
// and, when the tenant's `auto_classify` setting is enabled and the
|
|
371
|
+
// proposal confidence meets the tenant threshold, anchors it automatically
|
|
372
|
+
// (classification_status "proposed"/"universe_only" become real FK columns);
|
|
373
|
+
// below the threshold it stays a pending SUGGESTION the user reviews via
|
|
374
|
+
// memory_classification_pending / memory_classification_approve. See
|
|
375
|
+
// memory/src/api/routers/memories.py (classification/settings). The MCP
|
|
376
|
+
// layer no longer injects an implicit "universe" scope nor surfaces
|
|
377
|
+
// "available universes"; scope, universe_id and context_id are forwarded
|
|
378
|
+
// ONLY when the caller sets them (explicit overrides → status "manual",
|
|
379
|
+
// which anchors there directly and skips classification).
|
|
376
380
|
try {
|
|
377
381
|
const result = await memoryClient.storeMemory({
|
|
378
382
|
agent_id: args.agent_id,
|
|
@@ -890,7 +894,18 @@ const handlers = {
|
|
|
890
894
|
}
|
|
891
895
|
formatted += `\n`;
|
|
892
896
|
results.memories.forEach((mem, i) => {
|
|
893
|
-
|
|
897
|
+
// Q-MEM-02: /memories/search-with-attention returns `attention_score`
|
|
898
|
+
// (re-ranked) + `base_similarity` (see memory/src/api/schemas.py
|
|
899
|
+
// MemoryWithAttentionScore). The legacy field `weighted_score` and the
|
|
900
|
+
// plain /memories/search field `similarity` are kept as fallbacks so the
|
|
901
|
+
// same renderer works across endpoints. `?? ` (not `||`) so a real 0 score
|
|
902
|
+
// is not swallowed.
|
|
903
|
+
const score = mem.attention_score ??
|
|
904
|
+
mem.weighted_score ??
|
|
905
|
+
mem.base_similarity ??
|
|
906
|
+
mem.similarity;
|
|
907
|
+
const scoreTxt = typeof score === "number" ? score.toFixed(2) : "n/a";
|
|
908
|
+
formatted += `${i + 1}. [${mem.memory_type}] (id: ${mem.id}) (score: ${scoreTxt})\n`;
|
|
894
909
|
formatted += ` ${mem.content?.slice(0, 200)}...\n\n`;
|
|
895
910
|
});
|
|
896
911
|
return { content: [{ type: "text", text: formatted }] };
|
|
@@ -968,7 +983,7 @@ const handlers = {
|
|
|
968
983
|
// ==========================================================================
|
|
969
984
|
// Maintenance
|
|
970
985
|
// ==========================================================================
|
|
971
|
-
async heartbeat_tick(args, { memoryClient }) {
|
|
986
|
+
async heartbeat_tick(args, { memoryClient, cpClient }) {
|
|
972
987
|
const result = await memoryClient.heartbeatTick({
|
|
973
988
|
agent_id: args.agent_id,
|
|
974
989
|
context_id: args.context_id,
|
|
@@ -998,6 +1013,13 @@ const handlers = {
|
|
|
998
1013
|
formatted += ` ${action}: at interaction ${count}\n`;
|
|
999
1014
|
}
|
|
1000
1015
|
}
|
|
1016
|
+
// Piggyback the notifications check on the heartbeat. fetchNotificationsBlock
|
|
1017
|
+
// self-throttles (at most once per FRINUS_NOTIFICATIONS_INTERVAL_MIN) and
|
|
1018
|
+
// dedupes against what this session already showed, so this is cheap and
|
|
1019
|
+
// never shows the same item twice.
|
|
1020
|
+
const notif = await fetchNotificationsBlock(cpClient, { force: false });
|
|
1021
|
+
if (notif)
|
|
1022
|
+
formatted += `\n${notif}`;
|
|
1001
1023
|
return { content: [{ type: "text", text: formatted }] };
|
|
1002
1024
|
},
|
|
1003
1025
|
async sleep_run(args, { memoryClient }) {
|
|
@@ -1035,11 +1057,27 @@ const handlers = {
|
|
|
1035
1057
|
if (!conflicts || conflicts.length === 0) {
|
|
1036
1058
|
return { content: [{ type: "text", text: "No conflicts detected." }] };
|
|
1037
1059
|
}
|
|
1038
|
-
let formatted = `Detected ${result.conflicts_found
|
|
1060
|
+
let formatted = `Detected ${result.conflicts_found ?? conflicts.length} potential conflicts:\n\n`;
|
|
1039
1061
|
conflicts.forEach((conflict, i) => {
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1062
|
+
// Q-MEM-03: the backend emits `memory_1` / `memory_2` (ConflictPair in
|
|
1063
|
+
// memory/src/api/schemas.py), each with id/content, plus `conflict_type`
|
|
1064
|
+
// and `suggested_action`. The old code read `memory1`/`memory2` (no
|
|
1065
|
+
// underscore) so content printed as "undefined" and the type/action were
|
|
1066
|
+
// dropped. Keep the snake-less variants as a defensive fallback.
|
|
1067
|
+
const m1 = conflict.memory_1 ?? conflict.memory1;
|
|
1068
|
+
const m2 = conflict.memory_2 ?? conflict.memory2;
|
|
1069
|
+
const simTxt = typeof conflict.similarity === "number"
|
|
1070
|
+
? conflict.similarity.toFixed(2)
|
|
1071
|
+
: "n/a";
|
|
1072
|
+
formatted += `${i + 1}. Similarity: ${simTxt}`;
|
|
1073
|
+
if (conflict.conflict_type)
|
|
1074
|
+
formatted += ` · type: ${conflict.conflict_type}`;
|
|
1075
|
+
formatted += `\n`;
|
|
1076
|
+
formatted += ` Memory 1 (${m1?.id ?? "?"}): ${m1?.content?.slice(0, 100) ?? "(no content)"}...\n`;
|
|
1077
|
+
formatted += ` Memory 2 (${m2?.id ?? "?"}): ${m2?.content?.slice(0, 100) ?? "(no content)"}...\n`;
|
|
1078
|
+
if (conflict.suggested_action)
|
|
1079
|
+
formatted += ` Suggested action: ${conflict.suggested_action}\n`;
|
|
1080
|
+
formatted += `\n`;
|
|
1043
1081
|
});
|
|
1044
1082
|
return { content: [{ type: "text", text: formatted }] };
|
|
1045
1083
|
},
|
|
@@ -1062,12 +1100,19 @@ const handlers = {
|
|
|
1062
1100
|
if (!groups || groups.length === 0) {
|
|
1063
1101
|
return { content: [{ type: "text", text: "No redundant memories detected." }] };
|
|
1064
1102
|
}
|
|
1065
|
-
let formatted = `Detected ${result.groups_found
|
|
1103
|
+
let formatted = `Detected ${result.groups_found ?? groups.length} redundant memory groups (${result.total_redundant ?? 0} redundant memories):\n\n`;
|
|
1066
1104
|
groups.forEach((group, i) => {
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1105
|
+
// Q-MEM-04: the backend emits `keep` (the memory to retain), `redundant`
|
|
1106
|
+
// (the duplicates) and `total_in_group` (RedundantGroup in
|
|
1107
|
+
// memory/src/api/schemas.py). The old code read `avg_similarity`,
|
|
1108
|
+
// `memory_ids` and `memories`, none of which exist, so every group showed
|
|
1109
|
+
// "N/A" and 0 memories.
|
|
1110
|
+
const redundantCount = Array.isArray(group.redundant) ? group.redundant.length : 0;
|
|
1111
|
+
const total = group.total_in_group ?? (redundantCount + (group.keep ? 1 : 0));
|
|
1112
|
+
formatted += `${i + 1}. Memories in group: ${total} (keep 1, redundant ${redundantCount})\n`;
|
|
1113
|
+
const keepPreview = group.keep?.content?.slice(0, 100);
|
|
1114
|
+
if (keepPreview) {
|
|
1115
|
+
formatted += ` Keep (${group.keep?.id ?? "?"}): ${keepPreview}...\n`;
|
|
1071
1116
|
}
|
|
1072
1117
|
formatted += `\n`;
|
|
1073
1118
|
});
|
|
@@ -1209,7 +1254,12 @@ const handlers = {
|
|
|
1209
1254
|
}],
|
|
1210
1255
|
};
|
|
1211
1256
|
},
|
|
1212
|
-
async agent_bootstrap(args, { agentClient }) {
|
|
1257
|
+
async agent_bootstrap(args, { agentClient, cpClient }) {
|
|
1258
|
+
// Kick off the notifications fetch concurrently with the bootstrap call so
|
|
1259
|
+
// it adds no latency on the critical path. fetchNotificationsBlock never
|
|
1260
|
+
// throws and self-limits to a 4s timeout, so bootstrap can neither fail nor
|
|
1261
|
+
// stall because of it; the extra .catch is defence in depth.
|
|
1262
|
+
const notifPromise = fetchNotificationsBlock(cpClient, { force: true }).catch(() => "");
|
|
1213
1263
|
try {
|
|
1214
1264
|
const result = await agentClient.bootstrapAgent(args.agent_id);
|
|
1215
1265
|
const identity = result.identity || {};
|
|
@@ -1273,6 +1323,9 @@ const handlers = {
|
|
|
1273
1323
|
if (delegateDigest && delegateDigest.count > 0 && delegateDigest.summary) {
|
|
1274
1324
|
formatted += `\n### Delegate Targets\n${delegateDigest.summary}\n`;
|
|
1275
1325
|
}
|
|
1326
|
+
const notif = await notifPromise;
|
|
1327
|
+
if (notif)
|
|
1328
|
+
formatted += `\n\n${notif}`;
|
|
1276
1329
|
return { content: [{ type: "text", text: formatted }] };
|
|
1277
1330
|
}
|
|
1278
1331
|
catch (error) {
|
|
@@ -2279,6 +2332,81 @@ const handlers = {
|
|
|
2279
2332
|
throw error;
|
|
2280
2333
|
}
|
|
2281
2334
|
},
|
|
2335
|
+
// ==========================================================================
|
|
2336
|
+
// Platform Notifications
|
|
2337
|
+
// ==========================================================================
|
|
2338
|
+
async notifications_list(_args, { cpClient }) {
|
|
2339
|
+
try {
|
|
2340
|
+
// mark_delivered=false: this is a read-only peek that must NOT consume a
|
|
2341
|
+
// delivery against max_deliveries. Use a higher limit than the automatic
|
|
2342
|
+
// channels so the user can see everything still pending.
|
|
2343
|
+
const inbox = await cpClient.getNotificationInbox({
|
|
2344
|
+
channel: "mcp",
|
|
2345
|
+
limit: 10,
|
|
2346
|
+
markDelivered: false,
|
|
2347
|
+
});
|
|
2348
|
+
const items = inbox?.items || [];
|
|
2349
|
+
if (items.length === 0) {
|
|
2350
|
+
return { content: [{ type: "text", text: "No pending Frinus notifications." }] };
|
|
2351
|
+
}
|
|
2352
|
+
let text = `${items.length} pending Frinus notification${items.length === 1 ? "" : "s"}:\n\n`;
|
|
2353
|
+
items.forEach((it) => {
|
|
2354
|
+
const sev = it.severity ? `[${it.severity}] ` : "";
|
|
2355
|
+
text += `- ${sev}${it.title} (id: ${it.id})\n`;
|
|
2356
|
+
let body = (it.body || "").trim();
|
|
2357
|
+
if (body.length > 300)
|
|
2358
|
+
body = body.slice(0, 300).trimEnd() + "…";
|
|
2359
|
+
if (body)
|
|
2360
|
+
text += ` ${body.replace(/\n+/g, " ")}\n`;
|
|
2361
|
+
if (it.cta_url)
|
|
2362
|
+
text += ` → ${it.cta_label ? `${it.cta_label}: ` : ""}${it.cta_url}\n`;
|
|
2363
|
+
});
|
|
2364
|
+
text += `\nCall notification_ack(id) once the user has seen one so it stops appearing.`;
|
|
2365
|
+
return { content: [{ type: "text", text }] };
|
|
2366
|
+
}
|
|
2367
|
+
catch (error) {
|
|
2368
|
+
if (error?.response?.status === 404) {
|
|
2369
|
+
// CP without the notifications endpoint yet — treat as empty, not error.
|
|
2370
|
+
return { content: [{ type: "text", text: "No pending Frinus notifications." }] };
|
|
2371
|
+
}
|
|
2372
|
+
const apiErr = handleApiError(error);
|
|
2373
|
+
if (apiErr)
|
|
2374
|
+
return apiErr;
|
|
2375
|
+
throw error;
|
|
2376
|
+
}
|
|
2377
|
+
},
|
|
2378
|
+
async notification_ack(args, { cpClient }) {
|
|
2379
|
+
const id = args.id || "";
|
|
2380
|
+
if (!id) {
|
|
2381
|
+
return {
|
|
2382
|
+
content: [{ type: "text", text: "notification_ack requires an 'id'." }],
|
|
2383
|
+
isError: true,
|
|
2384
|
+
};
|
|
2385
|
+
}
|
|
2386
|
+
try {
|
|
2387
|
+
await cpClient.ackNotification(id);
|
|
2388
|
+
return {
|
|
2389
|
+
content: [{
|
|
2390
|
+
type: "text",
|
|
2391
|
+
text: `Notification ${id} acknowledged. It will no longer appear in the inbox.`,
|
|
2392
|
+
}],
|
|
2393
|
+
};
|
|
2394
|
+
}
|
|
2395
|
+
catch (error) {
|
|
2396
|
+
if (error?.response?.status === 404) {
|
|
2397
|
+
return {
|
|
2398
|
+
content: [{
|
|
2399
|
+
type: "text",
|
|
2400
|
+
text: `Notification ${id} not found (already acknowledged, expired, or not for you).`,
|
|
2401
|
+
}],
|
|
2402
|
+
};
|
|
2403
|
+
}
|
|
2404
|
+
const apiErr = handleApiError(error);
|
|
2405
|
+
if (apiErr)
|
|
2406
|
+
return apiErr;
|
|
2407
|
+
throw error;
|
|
2408
|
+
}
|
|
2409
|
+
},
|
|
2282
2410
|
};
|
|
2283
2411
|
// ---------------------------------------------------------------------------
|
|
2284
2412
|
// Dispatcher: looks up the handler and executes it with error handling
|
package/dist/types/index.d.ts
CHANGED
|
@@ -58,6 +58,24 @@ export interface AgentClientInterface {
|
|
|
58
58
|
timeout_seconds?: number;
|
|
59
59
|
}): Promise<any>;
|
|
60
60
|
}
|
|
61
|
+
/** A single platform notification served by the Control Plane inbox. */
|
|
62
|
+
export interface NotificationItem {
|
|
63
|
+
id: string;
|
|
64
|
+
slug: string;
|
|
65
|
+
title: string;
|
|
66
|
+
body: string;
|
|
67
|
+
kind: string;
|
|
68
|
+
severity: string;
|
|
69
|
+
cta_label?: string | null;
|
|
70
|
+
cta_url?: string | null;
|
|
71
|
+
starts_at?: string | null;
|
|
72
|
+
expires_at?: string | null;
|
|
73
|
+
}
|
|
74
|
+
/** Response shape of GET /api/v1/notifications/inbox. */
|
|
75
|
+
export interface NotificationInbox {
|
|
76
|
+
items: NotificationItem[];
|
|
77
|
+
count: number;
|
|
78
|
+
}
|
|
61
79
|
/** Public surface of the CpClient used by handlers. */
|
|
62
80
|
export interface CpClientInterface {
|
|
63
81
|
createUniverse(orgId: string, data: {
|
|
@@ -71,6 +89,12 @@ export interface CpClientInterface {
|
|
|
71
89
|
description?: string;
|
|
72
90
|
}): Promise<any>;
|
|
73
91
|
deleteUniverse(orgId: string, universeId: string): Promise<void>;
|
|
92
|
+
getNotificationInbox(opts?: {
|
|
93
|
+
channel?: string;
|
|
94
|
+
limit?: number;
|
|
95
|
+
markDelivered?: boolean;
|
|
96
|
+
}): Promise<NotificationInbox>;
|
|
97
|
+
ackNotification(id: string): Promise<void>;
|
|
74
98
|
storeCredential(integrationRef: string, data: Record<string, unknown>, metadata?: Record<string, unknown>): Promise<any>;
|
|
75
99
|
getCredential(integrationRef: string): Promise<any>;
|
|
76
100
|
listCredentials(): Promise<any>;
|
package/manifest.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"manifest_version": "0.3",
|
|
3
3
|
"name": "frinus",
|
|
4
4
|
"display_name": "Frinus — Memória Cognitiva",
|
|
5
|
-
"version": "3.
|
|
5
|
+
"version": "3.7.0",
|
|
6
6
|
"description": "Memória cognitiva de longo prazo para o Claude (φρήν + νοῦς). 83 ferramentas de memória, sessões, agentes e conhecimento.",
|
|
7
7
|
"long_description": "O Frinus dá ao Claude memória de longo prazo, identidade e inteligência persistente entre conversas. Conecta o Claude Desktop à plataforma Frinus (Memory Engine, Control Plane e Agent Service) via MCP. Basta colar sua chave de API pessoal — nada para instalar ou compilar.",
|
|
8
8
|
"author": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "frinus-mcp",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.7.0",
|
|
4
4
|
"description": "Frinus - MCP Server for Cognitive Memory (φρήν + νοῦς). Long-term memory, sessions, agents and knowledge for Claude and any MCP client.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
},
|
|
18
18
|
"scripts": {
|
|
19
19
|
"build": "tsc",
|
|
20
|
+
"test": "node --import tsx --test tests/*.test.ts",
|
|
20
21
|
"start": "node dist/index.js",
|
|
21
22
|
"dev": "tsx src/index.ts",
|
|
22
23
|
"pack:mcpb": "./scripts/pack-mcpb.sh",
|