frinus-mcp 3.1.0 → 3.2.1
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 +41 -0
- package/dist/client/memory-client.d.ts +1 -0
- package/dist/client/memory-client.js +5 -1
- package/dist/tools/definitions.js +71 -7
- package/dist/tools/handlers.js +357 -36
- package/manifest.json +1 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -307,6 +307,47 @@ Reusable behaviours assigned to agents.
|
|
|
307
307
|
| `skill_remove` | Detach a skill from an agent. |
|
|
308
308
|
| `skill_agent_list` | List the skills owned by a given agent. |
|
|
309
309
|
|
|
310
|
+
### Credential Vault (5) — `credential_exec` broker
|
|
311
|
+
|
|
312
|
+
Encrypted credentials stored in the Control Plane, referenced from memories via
|
|
313
|
+
`credential_ref`.
|
|
314
|
+
|
|
315
|
+
| Tool | Description |
|
|
316
|
+
|------|-------------|
|
|
317
|
+
| `credential_store` | Store an encrypted credential under a ref (e.g. `mysql_x`). |
|
|
318
|
+
| `credential_get` | Inspect **non-secret metadata only** (host/user/db/port + the env vars `credential_exec` will inject). Never returns the value. |
|
|
319
|
+
| `credential_exec` | **Run a command with the credential injected into the child process ENV.** Returns only stdout/stderr/exit_code — the secret never reaches the model, screen, or disk. |
|
|
320
|
+
| `credential_list` | List stored credential refs (no secret data). |
|
|
321
|
+
| `credential_delete` | Delete a stored credential. |
|
|
322
|
+
|
|
323
|
+
**Secret-handling principle — server-side broker, never by value.** The model
|
|
324
|
+
never receives a secret value: no plaintext, no temp files, no shell snippets that
|
|
325
|
+
carry the secret. To *use* a credential you call **`credential_exec`**. The MCP
|
|
326
|
+
server (already running locally via `npx -y frinus-mcp@latest`) fetches the
|
|
327
|
+
credential from the vault, injects its fields into the **environment** of a child
|
|
328
|
+
process — never into argv, never into any text the model sees — runs the command
|
|
329
|
+
with `shell:false` (no shell injection), and returns only the output.
|
|
330
|
+
|
|
331
|
+
```jsonc
|
|
332
|
+
// MYSQL_PWD / MYSQL_USER / MYSQL_HOST are pre-injected → standard clients just work
|
|
333
|
+
credential_exec(ref="mysql_prod", argv=["mysql", "-e", "SELECT 1"])
|
|
334
|
+
credential_exec(ref="pg_prod", argv=["psql", "-c", "SELECT 1"])
|
|
335
|
+
|
|
336
|
+
// For anything else, read the injected vars inside an explicit shell:
|
|
337
|
+
credential_exec(ref="jira_x", argv=["sh","-c",
|
|
338
|
+
"curl -sS -H \"Authorization: Bearer $CRED_TOKEN\" \"$CRED_BASE_URL/whoami\""])
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
Injected env vars (when present in the credential): password/secret/token →
|
|
342
|
+
`MYSQL_PWD`, `PGPASSWORD`, `CRED_PASSWORD`; `user`/`username` → `CRED_USER`,
|
|
343
|
+
`MYSQL_USER`, `PGUSER`; `host` → `CRED_HOST`, `MYSQL_HOST`, `PGHOST`; `port` →
|
|
344
|
+
`CRED_PORT`, `MYSQL_TCP_PORT`, `PGPORT`; `database` → `CRED_DATABASE`,
|
|
345
|
+
`PGDATABASE`; any other scalar → `CRED_<UPPER_SNAKE>` (e.g. `base_url` →
|
|
346
|
+
`CRED_BASE_URL`). `argv` must be an array of strings (no shell command string;
|
|
347
|
+
use `["sh","-c","..."]` if you really need a shell). 30s timeout, 256 KiB output
|
|
348
|
+
cap. Everything ships in the npm package — **the user installs nothing, edits no
|
|
349
|
+
PATH, and runs no extra command**; an up-to-date MCP is all that's required.
|
|
350
|
+
|
|
310
351
|
## Memory Types
|
|
311
352
|
|
|
312
353
|
| Type | Use Case | Example |
|
|
@@ -86,10 +86,12 @@ export class MemoryClient {
|
|
|
86
86
|
agent_id: data.agent_id,
|
|
87
87
|
content: data.content,
|
|
88
88
|
memory_type: data.memory_type || "episodic",
|
|
89
|
-
scope: data.scope || "organization",
|
|
90
89
|
importance: data.importance || 0.5,
|
|
91
90
|
metadata: data.metadata,
|
|
92
91
|
};
|
|
92
|
+
// The handler is the source of truth for scope; only send it when defined.
|
|
93
|
+
if (data.scope)
|
|
94
|
+
payload.scope = data.scope;
|
|
93
95
|
if (data.user_id)
|
|
94
96
|
payload.user_id = data.user_id;
|
|
95
97
|
if (data.created_by_user_id)
|
|
@@ -98,6 +100,8 @@ export class MemoryClient {
|
|
|
98
100
|
payload.context_id = data.context_id;
|
|
99
101
|
if (data.credential_ref)
|
|
100
102
|
payload.credential_ref = data.credential_ref;
|
|
103
|
+
if (data.universe_id)
|
|
104
|
+
payload.universe_id = data.universe_id;
|
|
101
105
|
const response = await this.client.post("/memories", payload);
|
|
102
106
|
return response.data;
|
|
103
107
|
}
|
|
@@ -38,7 +38,7 @@ Scopes:
|
|
|
38
38
|
scope: {
|
|
39
39
|
type: "string",
|
|
40
40
|
enum: ["user", "agent", "universe", "organization"],
|
|
41
|
-
description: "Visibility scope:
|
|
41
|
+
description: "Visibility scope. Padrão: a memória é associada ao universo do agente (contexto). 'organization' (org-wide) e 'user' (private) só são usados quando explicitamente definidos; 'agent' (agent-only) é o fallback automático quando não há universo identificável.",
|
|
42
42
|
},
|
|
43
43
|
importance: {
|
|
44
44
|
type: "number",
|
|
@@ -56,6 +56,10 @@ Scopes:
|
|
|
56
56
|
type: "object",
|
|
57
57
|
description: "Metadata adicional (tags, universe, idioma, etc.)",
|
|
58
58
|
},
|
|
59
|
+
universe_id: {
|
|
60
|
+
type: "string",
|
|
61
|
+
description: "UUID do universo/contexto ao qual associar a memória. Se omitido, a memória é associada automaticamente ao universo do agente.",
|
|
62
|
+
},
|
|
59
63
|
context_id: {
|
|
60
64
|
type: "string",
|
|
61
65
|
description: "Optional UUID of a hierarchy node (universe, concept, theme, topic, or point). When provided, the memory is automatically linked to this node and all its ancestors. Takes precedence over universe_id for hierarchy placement.",
|
|
@@ -1499,24 +1503,84 @@ to link a memory to this credential.`,
|
|
|
1499
1503
|
},
|
|
1500
1504
|
{
|
|
1501
1505
|
name: "credential_get",
|
|
1502
|
-
description: `
|
|
1506
|
+
description: `Inspect a credential's NON-SECRET metadata — never its value.
|
|
1503
1507
|
|
|
1504
|
-
|
|
1505
|
-
|
|
1508
|
+
This tool returns ONLY non-secret metadata (host/user/db/port), the names of the
|
|
1509
|
+
secret fields, and the names of the environment variables that 'credential_exec'
|
|
1510
|
+
will inject. The secret VALUE is NEVER returned, so it can never be echoed inline
|
|
1511
|
+
into a shell command and leaked on a screen share.
|
|
1506
1512
|
|
|
1507
|
-
|
|
1508
|
-
|
|
1513
|
+
To actually USE the credential (connect to a DB, call an API), do NOT request the
|
|
1514
|
+
value — call 'credential_exec'. It fetches the secret on the server side, injects
|
|
1515
|
+
it into the child process environment, runs your command, and returns only the
|
|
1516
|
+
output. The secret never reaches you, the screen, or disk.
|
|
1517
|
+
|
|
1518
|
+
Only the credential owner (authenticated API key) can access their credentials.`,
|
|
1509
1519
|
inputSchema: {
|
|
1510
1520
|
type: "object",
|
|
1511
1521
|
properties: {
|
|
1512
1522
|
ref: {
|
|
1513
1523
|
type: "string",
|
|
1514
|
-
description: "Credential reference to
|
|
1524
|
+
description: "Credential reference to inspect (e.g., 'jira_muza')",
|
|
1515
1525
|
},
|
|
1516
1526
|
},
|
|
1517
1527
|
required: ["ref"],
|
|
1518
1528
|
},
|
|
1519
1529
|
},
|
|
1530
|
+
{
|
|
1531
|
+
name: "credential_exec",
|
|
1532
|
+
description: `Run a command with a vault credential injected into its environment — the secret NEVER reaches you.
|
|
1533
|
+
|
|
1534
|
+
This is the secure way to USE a stored credential. The MCP server (running locally
|
|
1535
|
+
via npx) fetches the credential from the vault, injects its fields into the
|
|
1536
|
+
ENVIRONMENT of a child process (never into argv, never into any text you see),
|
|
1537
|
+
runs your command with shell:false (no shell injection), and returns ONLY
|
|
1538
|
+
stdout, stderr, and exit_code. The secret value is never returned to you, never
|
|
1539
|
+
printed on screen, never logged, and never written to disk.
|
|
1540
|
+
|
|
1541
|
+
Injected environment variables (when present in the credential):
|
|
1542
|
+
- password/secret/token field -> MYSQL_PWD, PGPASSWORD, CRED_PASSWORD
|
|
1543
|
+
- user / username -> CRED_USER, MYSQL_USER, PGUSER
|
|
1544
|
+
- host / hostname -> CRED_HOST, MYSQL_HOST, PGHOST
|
|
1545
|
+
- port -> CRED_PORT, MYSQL_TCP_PORT, PGPORT
|
|
1546
|
+
- database / db / dbname -> CRED_DATABASE, PGDATABASE
|
|
1547
|
+
- any other scalar field -> CRED_<UPPER_SNAKE_NAME> (e.g. base_url -> CRED_BASE_URL)
|
|
1548
|
+
|
|
1549
|
+
Because MYSQL_PWD / PGPASSWORD etc. are standard, common clients pick them up
|
|
1550
|
+
with no flags:
|
|
1551
|
+
credential_exec(ref="mysql_prod", argv=["mysql", "-e", "SELECT 1"])
|
|
1552
|
+
credential_exec(ref="pg_prod", argv=["psql", "-c", "SELECT 1"])
|
|
1553
|
+
|
|
1554
|
+
For anything else, read the injected vars inside an explicit shell — the secret
|
|
1555
|
+
is expanded inside the child, never in text you author:
|
|
1556
|
+
credential_exec(ref="jira_x", argv=["sh", "-c",
|
|
1557
|
+
"curl -sS -H \\"Authorization: Bearer $CRED_TOKEN\\" \\"$CRED_BASE_URL/whoami\\""])
|
|
1558
|
+
|
|
1559
|
+
RULES:
|
|
1560
|
+
- 'argv' MUST be an array of strings (program first, then args). A shell command
|
|
1561
|
+
string is NOT accepted; pass ["sh","-c","..."] only when you genuinely need a shell.
|
|
1562
|
+
- NEVER put a secret value in argv or ask for the value — there is no path to it.
|
|
1563
|
+
- 30s timeout; output capped at 256 KiB per stream.`,
|
|
1564
|
+
inputSchema: {
|
|
1565
|
+
type: "object",
|
|
1566
|
+
properties: {
|
|
1567
|
+
ref: {
|
|
1568
|
+
type: "string",
|
|
1569
|
+
description: "Credential reference to use (e.g., 'mysql_centerpag', 'jira_muza').",
|
|
1570
|
+
},
|
|
1571
|
+
argv: {
|
|
1572
|
+
type: "array",
|
|
1573
|
+
items: { type: "string" },
|
|
1574
|
+
description: "Command as an array: program first, then each argument as a separate string. E.g. [\"mysql\", \"-e\", \"SELECT 1\"]. NOT a shell string.",
|
|
1575
|
+
},
|
|
1576
|
+
stdin: {
|
|
1577
|
+
type: "string",
|
|
1578
|
+
description: "Optional data to pipe to the command's stdin (e.g. a SQL script).",
|
|
1579
|
+
},
|
|
1580
|
+
},
|
|
1581
|
+
required: ["ref", "argv"],
|
|
1582
|
+
},
|
|
1583
|
+
},
|
|
1520
1584
|
{
|
|
1521
1585
|
name: "credential_list",
|
|
1522
1586
|
description: `List all stored credentials for the current user.
|
package/dist/tools/handlers.js
CHANGED
|
@@ -5,8 +5,166 @@
|
|
|
5
5
|
* and return formatted MCP tool results.
|
|
6
6
|
*/
|
|
7
7
|
import axios from "axios";
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
8
9
|
import { getResolvedTenantOrgId } from "../client/memory-client.js";
|
|
9
10
|
// ---------------------------------------------------------------------------
|
|
11
|
+
// Credential delivery — server-side broker, never by value
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// SECURITY: agents running in Claude Code render the full bash command string
|
|
14
|
+
// on screen. If a secret value ever enters the agent's text context, the agent
|
|
15
|
+
// can (and historically did) splice it inline into a command like
|
|
16
|
+
// `export MYSQL_PWD='...'`, leaking it in plaintext during screen sharing.
|
|
17
|
+
//
|
|
18
|
+
// The structural fix is to NEVER return the secret value to the model at all,
|
|
19
|
+
// and — going one step further — to never even require the agent to hold the
|
|
20
|
+
// secret as a shell-side reference. The MCP server already runs locally (via
|
|
21
|
+
// `npx -y frinus-mcp@latest`) and already fetches the credential from the
|
|
22
|
+
// Control Plane vault. So the broker lives INSIDE this process as a native
|
|
23
|
+
// tool: `credential_exec`.
|
|
24
|
+
//
|
|
25
|
+
// `credential_exec` fetches the credential, injects the fields into the ENV of
|
|
26
|
+
// a child process (never into argv, never into the model's text), spawns the
|
|
27
|
+
// command with shell:false, and returns ONLY stdout/stderr/exit_code. The
|
|
28
|
+
// secret never enters the tool result, is never logged, and never touches disk.
|
|
29
|
+
// Everything ships in the npm package — the user installs nothing, edits no
|
|
30
|
+
// PATH, and runs no extra command.
|
|
31
|
+
//
|
|
32
|
+
// `credential_get` no longer surfaces any secret-bearing snippet: it returns
|
|
33
|
+
// non-sensitive metadata only and points the agent at `credential_exec`.
|
|
34
|
+
/** Keys (case-insensitive) treated as sensitive when surfacing metadata. */
|
|
35
|
+
const SECRET_KEY_RE = /pass(word|wd)?|secret|token|api[_-]?key|private[_-]?key|credential/i;
|
|
36
|
+
function looksLikePassword(key) {
|
|
37
|
+
return SECRET_KEY_RE.test(key);
|
|
38
|
+
}
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// credential_exec internals
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
const CRED_EXEC_TIMEOUT_MS = 30_000;
|
|
43
|
+
const CRED_EXEC_MAX_OUTPUT = 256 * 1024; // 256 KiB cap per stream
|
|
44
|
+
/**
|
|
45
|
+
* Map credential fields onto environment variables for the child process.
|
|
46
|
+
* The secret value flows ONLY into this env map — never into argv, never into
|
|
47
|
+
* any returned/ logged text.
|
|
48
|
+
*
|
|
49
|
+
* - Password-like field -> MYSQL_PWD, PGPASSWORD, CRED_PASSWORD
|
|
50
|
+
* - user/username -> CRED_USER (+ MYSQL_USER, PGUSER)
|
|
51
|
+
* - host/hostname -> CRED_HOST (+ MYSQL_HOST, PGHOST)
|
|
52
|
+
* - port -> CRED_PORT (+ MYSQL_TCP_PORT, PGPORT)
|
|
53
|
+
* - database/db/dbname -> CRED_DATABASE (+ PGDATABASE)
|
|
54
|
+
* - any other scalar -> CRED_<UPPER_SNAKE> (e.g. base_url -> CRED_BASE_URL)
|
|
55
|
+
*/
|
|
56
|
+
function buildCredentialEnv(data) {
|
|
57
|
+
const env = {};
|
|
58
|
+
const isScalar = (v) => typeof v === "string" || typeof v === "number" || typeof v === "boolean";
|
|
59
|
+
let passwordSet = false;
|
|
60
|
+
let userSet = false;
|
|
61
|
+
let hostSet = false;
|
|
62
|
+
let portSet = false;
|
|
63
|
+
let dbSet = false;
|
|
64
|
+
for (const [k, v] of Object.entries(data)) {
|
|
65
|
+
if (!isScalar(v))
|
|
66
|
+
continue;
|
|
67
|
+
const val = String(v);
|
|
68
|
+
const lk = k.toLowerCase();
|
|
69
|
+
// Generic CRED_<NAME> for every scalar field (raw key, upper-snaked).
|
|
70
|
+
const credKey = "CRED_" + k.toUpperCase().replace(/[^A-Z0-9]+/g, "_").replace(/^_+|_+$/g, "");
|
|
71
|
+
env[credKey] = val;
|
|
72
|
+
if (!passwordSet && looksLikePassword(k)) {
|
|
73
|
+
env.CRED_PASSWORD = val;
|
|
74
|
+
env.MYSQL_PWD = val;
|
|
75
|
+
env.PGPASSWORD = val;
|
|
76
|
+
passwordSet = true;
|
|
77
|
+
}
|
|
78
|
+
else if (!userSet && (lk === "user" || lk === "username")) {
|
|
79
|
+
env.CRED_USER = val;
|
|
80
|
+
env.MYSQL_USER = val;
|
|
81
|
+
env.PGUSER = val;
|
|
82
|
+
userSet = true;
|
|
83
|
+
}
|
|
84
|
+
else if (!hostSet && (lk === "host" || lk === "hostname")) {
|
|
85
|
+
env.CRED_HOST = val;
|
|
86
|
+
env.MYSQL_HOST = val;
|
|
87
|
+
env.PGHOST = val;
|
|
88
|
+
hostSet = true;
|
|
89
|
+
}
|
|
90
|
+
else if (!portSet && lk === "port") {
|
|
91
|
+
env.CRED_PORT = val;
|
|
92
|
+
env.MYSQL_TCP_PORT = val;
|
|
93
|
+
env.PGPORT = val;
|
|
94
|
+
portSet = true;
|
|
95
|
+
}
|
|
96
|
+
else if (!dbSet && (lk === "database" || lk === "db" || lk === "dbname")) {
|
|
97
|
+
env.CRED_DATABASE = val;
|
|
98
|
+
env.PGDATABASE = val;
|
|
99
|
+
dbSet = true;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return env;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Spawn `argv` (program + args, shell:false) with the credential env injected
|
|
106
|
+
* on top of a minimal copy of the current env. Captures stdout/stderr with a
|
|
107
|
+
* size cap and a hard timeout. Never throws on a non-zero exit; throws only on
|
|
108
|
+
* spawn failure (e.g. command not found).
|
|
109
|
+
*/
|
|
110
|
+
function runWithCredEnv(argv, credEnv, stdin) {
|
|
111
|
+
return new Promise((resolve, reject) => {
|
|
112
|
+
const child = spawn(argv[0], argv.slice(1), {
|
|
113
|
+
shell: false,
|
|
114
|
+
env: { ...process.env, ...credEnv },
|
|
115
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
116
|
+
});
|
|
117
|
+
let stdout = "";
|
|
118
|
+
let stderr = "";
|
|
119
|
+
let truncated = false;
|
|
120
|
+
let timedOut = false;
|
|
121
|
+
let settled = false;
|
|
122
|
+
const timer = setTimeout(() => {
|
|
123
|
+
timedOut = true;
|
|
124
|
+
child.kill("SIGKILL");
|
|
125
|
+
}, CRED_EXEC_TIMEOUT_MS);
|
|
126
|
+
const append = (buf, chunk) => {
|
|
127
|
+
if (buf.length >= CRED_EXEC_MAX_OUTPUT) {
|
|
128
|
+
truncated = true;
|
|
129
|
+
return buf;
|
|
130
|
+
}
|
|
131
|
+
const next = buf + chunk.toString("utf8");
|
|
132
|
+
if (next.length > CRED_EXEC_MAX_OUTPUT) {
|
|
133
|
+
truncated = true;
|
|
134
|
+
return next.slice(0, CRED_EXEC_MAX_OUTPUT);
|
|
135
|
+
}
|
|
136
|
+
return next;
|
|
137
|
+
};
|
|
138
|
+
child.stdout.on("data", (c) => { stdout = append(stdout, c); });
|
|
139
|
+
child.stderr.on("data", (c) => { stderr = append(stderr, c); });
|
|
140
|
+
child.on("error", (err) => {
|
|
141
|
+
if (settled)
|
|
142
|
+
return;
|
|
143
|
+
settled = true;
|
|
144
|
+
clearTimeout(timer);
|
|
145
|
+
reject(err);
|
|
146
|
+
});
|
|
147
|
+
child.on("close", (code, signal) => {
|
|
148
|
+
if (settled)
|
|
149
|
+
return;
|
|
150
|
+
settled = true;
|
|
151
|
+
clearTimeout(timer);
|
|
152
|
+
resolve({
|
|
153
|
+
stdout,
|
|
154
|
+
stderr,
|
|
155
|
+
exit_code: code,
|
|
156
|
+
signal: signal ?? null,
|
|
157
|
+
timed_out: timedOut,
|
|
158
|
+
truncated,
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
if (typeof stdin === "string") {
|
|
162
|
+
child.stdin.write(stdin);
|
|
163
|
+
}
|
|
164
|
+
child.stdin.end();
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
10
168
|
// Helper: 401/403 error handling
|
|
11
169
|
// ---------------------------------------------------------------------------
|
|
12
170
|
export function handleApiError(error) {
|
|
@@ -42,39 +200,33 @@ const handlers = {
|
|
|
42
200
|
// ==========================================================================
|
|
43
201
|
// Core Memory
|
|
44
202
|
// ==========================================================================
|
|
45
|
-
async memory_store(args, { memoryClient, resolvedUserId }) {
|
|
203
|
+
async memory_store(args, { memoryClient, cpClient, resolvedUserId }) {
|
|
204
|
+
const createdByUser = args.created_by_user_id || resolvedUserId || undefined;
|
|
205
|
+
const userId = args.user_id || resolvedUserId || undefined;
|
|
206
|
+
const meta = args.metadata || {};
|
|
207
|
+
const universeId = args.universe_id || meta.universe_id || undefined;
|
|
208
|
+
// Context-first default: when the caller does NOT pass an explicit `scope`,
|
|
209
|
+
// memories are associated with a CONTEXT (universe). The backend resolves
|
|
210
|
+
// the universe_id from the agent when none is supplied. `organization` and
|
|
211
|
+
// `user` are only used when explicitly requested by the caller. When the
|
|
212
|
+
// agent has no resolvable universe, we fall back to `agent` scope and help
|
|
213
|
+
// future categorization by returning the list of available universes.
|
|
214
|
+
const explicitScope = args.scope;
|
|
215
|
+
const resolvedScope = explicitScope || "universe";
|
|
216
|
+
const baseInput = {
|
|
217
|
+
agent_id: args.agent_id,
|
|
218
|
+
content: args.content,
|
|
219
|
+
memory_type: args.memory_type,
|
|
220
|
+
importance: args.importance,
|
|
221
|
+
user_id: userId,
|
|
222
|
+
created_by_user_id: createdByUser,
|
|
223
|
+
metadata: args.metadata,
|
|
224
|
+
context_id: args.context_id,
|
|
225
|
+
credential_ref: args.credential_ref,
|
|
226
|
+
universe_id: universeId,
|
|
227
|
+
};
|
|
46
228
|
try {
|
|
47
|
-
const
|
|
48
|
-
const userId = args.user_id || resolvedUserId || undefined;
|
|
49
|
-
// Auto-scope inference: if scope not provided, pick the narrowest
|
|
50
|
-
// scope justified by the available context. Caller can always pass
|
|
51
|
-
// an explicit `scope` to override this heuristic.
|
|
52
|
-
const meta = args.metadata || {};
|
|
53
|
-
const universeId = args.universe_id || meta.universe_id || undefined;
|
|
54
|
-
let resolvedScope = args.scope;
|
|
55
|
-
if (!resolvedScope) {
|
|
56
|
-
if (userId || createdByUser) {
|
|
57
|
-
resolvedScope = "user";
|
|
58
|
-
}
|
|
59
|
-
else if (universeId) {
|
|
60
|
-
resolvedScope = "universe";
|
|
61
|
-
}
|
|
62
|
-
else {
|
|
63
|
-
resolvedScope = "organization";
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
const result = await memoryClient.storeMemory({
|
|
67
|
-
agent_id: args.agent_id,
|
|
68
|
-
content: args.content,
|
|
69
|
-
memory_type: args.memory_type,
|
|
70
|
-
scope: resolvedScope,
|
|
71
|
-
importance: args.importance,
|
|
72
|
-
user_id: userId,
|
|
73
|
-
created_by_user_id: createdByUser,
|
|
74
|
-
metadata: args.metadata,
|
|
75
|
-
context_id: args.context_id,
|
|
76
|
-
credential_ref: args.credential_ref,
|
|
77
|
-
});
|
|
229
|
+
const result = await memoryClient.storeMemory({ ...baseInput, scope: resolvedScope });
|
|
78
230
|
return {
|
|
79
231
|
content: [{
|
|
80
232
|
type: "text",
|
|
@@ -83,6 +235,45 @@ const handlers = {
|
|
|
83
235
|
};
|
|
84
236
|
}
|
|
85
237
|
catch (error) {
|
|
238
|
+
// Fallback: scope was implicitly "universe" but the agent has no
|
|
239
|
+
// resolvable universe (backend returns 400 "must be linked to a
|
|
240
|
+
// universe"). Retry as `agent` scope and surface available universes.
|
|
241
|
+
const status = error?.response?.status;
|
|
242
|
+
const detail = String(error?.response?.data?.detail ?? "");
|
|
243
|
+
const isMissingUniverse = status === 400 &&
|
|
244
|
+
!explicitScope &&
|
|
245
|
+
!universeId &&
|
|
246
|
+
detail.includes("must be linked to a universe");
|
|
247
|
+
if (isMissingUniverse) {
|
|
248
|
+
try {
|
|
249
|
+
const result = await memoryClient.storeMemory({ ...baseInput, scope: "agent" });
|
|
250
|
+
let text = `Memory stored successfully.\nID: ${result.id}\nType: ${result.memory_type}\nScope: ${result.scope}`;
|
|
251
|
+
// Enrich with available universes to help future categorization.
|
|
252
|
+
try {
|
|
253
|
+
const orgId = getResolvedTenantOrgId();
|
|
254
|
+
if (orgId) {
|
|
255
|
+
const list = await cpClient.listUniverses(orgId);
|
|
256
|
+
const universes = Array.isArray(list) ? list : (list.universes || list.items || []);
|
|
257
|
+
if (universes && universes.length > 0) {
|
|
258
|
+
text += `\n\nDica: esta memória não foi associada a um universo (salva como memória do agente). Para categorizá-la, informe universe_id na próxima vez. Universos disponíveis:`;
|
|
259
|
+
universes.forEach((u) => {
|
|
260
|
+
text += `\n- ${u.name} (id: ${u.id})`;
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
// Listing universes is best-effort; never break a successful store.
|
|
267
|
+
}
|
|
268
|
+
return { content: [{ type: "text", text }] };
|
|
269
|
+
}
|
|
270
|
+
catch (retryError) {
|
|
271
|
+
const retryApiErr = handleApiError(retryError);
|
|
272
|
+
if (retryApiErr)
|
|
273
|
+
return retryApiErr;
|
|
274
|
+
throw retryError;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
86
277
|
const apiErr = handleApiError(error);
|
|
87
278
|
if (apiErr)
|
|
88
279
|
return apiErr;
|
|
@@ -1472,11 +1663,57 @@ const handlers = {
|
|
|
1472
1663
|
try {
|
|
1473
1664
|
const ref = args.ref;
|
|
1474
1665
|
const result = await cpClient.getCredential(ref);
|
|
1666
|
+
const data = (result ?? {});
|
|
1667
|
+
// SERVER-SIDE BROKER MODEL: we never return any secret value here, and we
|
|
1668
|
+
// no longer emit shell snippets that handle the secret either. We surface
|
|
1669
|
+
// ONLY non-sensitive metadata (host/user/db/port) plus the names of the
|
|
1670
|
+
// secret fields and the env vars they will populate, and we instruct the
|
|
1671
|
+
// agent to run the command through `credential_exec`, which injects the
|
|
1672
|
+
// secret into the child process env on the server side — the value never
|
|
1673
|
+
// reaches the agent, the screen, or disk.
|
|
1674
|
+
const nonSecret = {};
|
|
1675
|
+
const secretFields = [];
|
|
1676
|
+
const credEnvKeys = Object.keys(buildCredentialEnv(data)).sort();
|
|
1677
|
+
for (const [k, v] of Object.entries(data)) {
|
|
1678
|
+
if (typeof v === "object" && v !== null)
|
|
1679
|
+
continue;
|
|
1680
|
+
if (looksLikePassword(k))
|
|
1681
|
+
secretFields.push(k);
|
|
1682
|
+
else
|
|
1683
|
+
nonSecret[k] = v;
|
|
1684
|
+
}
|
|
1685
|
+
const hasUser = "user" in data || "username" in data;
|
|
1686
|
+
const isDbLike = hasUser && secretFields.length > 0;
|
|
1687
|
+
const lines = [];
|
|
1688
|
+
lines.push(`Credential '${ref}' resolved (metadata only — NO secret value is ever returned).`);
|
|
1689
|
+
lines.push("To USE this credential, call the `credential_exec` tool. It fetches the " +
|
|
1690
|
+
"secret on the server side, injects it into the child process environment, " +
|
|
1691
|
+
"runs your command, and returns ONLY stdout/stderr/exit_code. The secret " +
|
|
1692
|
+
"never enters this text, your context, the screen, or disk.");
|
|
1693
|
+
lines.push("");
|
|
1694
|
+
if (Object.keys(nonSecret).length > 0) {
|
|
1695
|
+
lines.push("Non-secret metadata: " + JSON.stringify(nonSecret));
|
|
1696
|
+
}
|
|
1697
|
+
if (secretFields.length > 0) {
|
|
1698
|
+
lines.push(`Secret fields (handled server-side only): ${secretFields.join(", ")}`);
|
|
1699
|
+
}
|
|
1700
|
+
lines.push(`Env vars injected into the child by credential_exec: ${credEnvKeys.join(", ")}`);
|
|
1701
|
+
lines.push("");
|
|
1702
|
+
lines.push("DO NOT request, print, or inline the secret value. Use credential_exec:");
|
|
1703
|
+
lines.push("");
|
|
1704
|
+
if (isDbLike) {
|
|
1705
|
+
lines.push("# MySQL (MYSQL_PWD / MYSQL_USER / MYSQL_HOST are pre-injected):");
|
|
1706
|
+
lines.push(`credential_exec(ref="${ref}", argv=["mysql", "-e", "SELECT 1"])`);
|
|
1707
|
+
lines.push("");
|
|
1708
|
+
lines.push("# Postgres (PGPASSWORD / PGUSER / PGHOST / PGDATABASE are pre-injected):");
|
|
1709
|
+
lines.push(`credential_exec(ref="${ref}", argv=["psql", "-c", "SELECT 1"])`);
|
|
1710
|
+
}
|
|
1711
|
+
else {
|
|
1712
|
+
lines.push("# Read an injected field inside the command (here CRED_API_KEY, etc.):");
|
|
1713
|
+
lines.push(`credential_exec(ref="${ref}", argv=["sh", "-c", "curl -sS -H \\"Authorization: Bearer $CRED_TOKEN\\" \\"$CRED_BASE_URL/whoami\\""])`);
|
|
1714
|
+
}
|
|
1475
1715
|
return {
|
|
1476
|
-
content: [{
|
|
1477
|
-
type: "text",
|
|
1478
|
-
text: JSON.stringify(result, null, 2),
|
|
1479
|
-
}],
|
|
1716
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
1480
1717
|
};
|
|
1481
1718
|
}
|
|
1482
1719
|
catch (error) {
|
|
@@ -1494,6 +1731,90 @@ const handlers = {
|
|
|
1494
1731
|
throw error;
|
|
1495
1732
|
}
|
|
1496
1733
|
},
|
|
1734
|
+
// credential_exec — server-side broker. Runs a command with the credential
|
|
1735
|
+
// injected into the child process ENV. Returns ONLY stdout/stderr/exit_code.
|
|
1736
|
+
// The secret value never enters the returned text and is never logged.
|
|
1737
|
+
async credential_exec(args, { cpClient }) {
|
|
1738
|
+
const ref = args.ref;
|
|
1739
|
+
const argv = args.argv;
|
|
1740
|
+
const stdin = args.stdin;
|
|
1741
|
+
// Validate argv: must be a non-empty array of strings (program + args).
|
|
1742
|
+
// We deliberately do NOT accept a shell command string — spawn runs with
|
|
1743
|
+
// shell:false to avoid shell injection. The caller can still opt into a
|
|
1744
|
+
// shell explicitly with argv ["sh","-c","..."], which is their choice.
|
|
1745
|
+
if (!Array.isArray(argv) || argv.length === 0 || !argv.every((a) => typeof a === "string")) {
|
|
1746
|
+
return {
|
|
1747
|
+
content: [{
|
|
1748
|
+
type: "text",
|
|
1749
|
+
text: "argv must be a non-empty array of strings (program + args), e.g. [\"mysql\", \"-e\", \"SELECT 1\"]. A shell command string is not accepted (use [\"sh\", \"-c\", \"...\"] if you really need a shell).",
|
|
1750
|
+
}],
|
|
1751
|
+
isError: true,
|
|
1752
|
+
};
|
|
1753
|
+
}
|
|
1754
|
+
if (stdin !== undefined && typeof stdin !== "string") {
|
|
1755
|
+
return {
|
|
1756
|
+
content: [{ type: "text", text: "stdin, if provided, must be a string." }],
|
|
1757
|
+
isError: true,
|
|
1758
|
+
};
|
|
1759
|
+
}
|
|
1760
|
+
let credData;
|
|
1761
|
+
try {
|
|
1762
|
+
const result = await cpClient.getCredential(ref);
|
|
1763
|
+
credData = (result ?? {});
|
|
1764
|
+
}
|
|
1765
|
+
catch (error) {
|
|
1766
|
+
if (error?.response?.status === 404) {
|
|
1767
|
+
return {
|
|
1768
|
+
content: [{ type: "text", text: `No credential found for ref '${ref}'` }],
|
|
1769
|
+
isError: true,
|
|
1770
|
+
};
|
|
1771
|
+
}
|
|
1772
|
+
const apiErr = handleApiError(error);
|
|
1773
|
+
if (apiErr)
|
|
1774
|
+
return apiErr;
|
|
1775
|
+
throw error;
|
|
1776
|
+
}
|
|
1777
|
+
if (Object.keys(credData).length === 0) {
|
|
1778
|
+
return {
|
|
1779
|
+
content: [{ type: "text", text: `Credential '${ref}' has no fields to inject.` }],
|
|
1780
|
+
isError: true,
|
|
1781
|
+
};
|
|
1782
|
+
}
|
|
1783
|
+
const credEnv = buildCredentialEnv(credData);
|
|
1784
|
+
let res;
|
|
1785
|
+
try {
|
|
1786
|
+
res = await runWithCredEnv(argv, credEnv, stdin);
|
|
1787
|
+
}
|
|
1788
|
+
catch (err) {
|
|
1789
|
+
// Spawn failure (e.g. ENOENT command not found). The error message is
|
|
1790
|
+
// about the program/argv, which the caller already provided — no secret.
|
|
1791
|
+
return {
|
|
1792
|
+
content: [{
|
|
1793
|
+
type: "text",
|
|
1794
|
+
text: `Failed to execute command: ${err?.code ? err.code + " " : ""}${err?.message ?? String(err)}`,
|
|
1795
|
+
}],
|
|
1796
|
+
isError: true,
|
|
1797
|
+
};
|
|
1798
|
+
}
|
|
1799
|
+
// Return ONLY non-secret execution results. The credential value lived only
|
|
1800
|
+
// in the child's env; it is not part of stdout/stderr unless the invoked
|
|
1801
|
+
// command itself chose to print it (the caller's responsibility).
|
|
1802
|
+
const summary = [
|
|
1803
|
+
`exit_code: ${res.exit_code}`,
|
|
1804
|
+
res.signal ? `signal: ${res.signal}` : null,
|
|
1805
|
+
res.timed_out ? "timed_out: true (killed after 30s)" : null,
|
|
1806
|
+
res.truncated ? "truncated: true (output capped at 256 KiB)" : null,
|
|
1807
|
+
"",
|
|
1808
|
+
"--- stdout ---",
|
|
1809
|
+
res.stdout.length ? res.stdout : "(empty)",
|
|
1810
|
+
"--- stderr ---",
|
|
1811
|
+
res.stderr.length ? res.stderr : "(empty)",
|
|
1812
|
+
].filter((l) => l !== null).join("\n");
|
|
1813
|
+
return {
|
|
1814
|
+
content: [{ type: "text", text: summary }],
|
|
1815
|
+
isError: res.exit_code !== 0,
|
|
1816
|
+
};
|
|
1817
|
+
},
|
|
1497
1818
|
async credential_list(_args, { cpClient }) {
|
|
1498
1819
|
try {
|
|
1499
1820
|
const result = await cpClient.listCredentials();
|
package/manifest.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"manifest_version": "0.
|
|
2
|
+
"manifest_version": "0.3",
|
|
3
3
|
"name": "frinus",
|
|
4
4
|
"display_name": "Frinus — Memória Cognitiva",
|
|
5
5
|
"version": "3.1.0",
|
|
@@ -12,7 +12,6 @@
|
|
|
12
12
|
"homepage": "https://frinus.rdxsec.com.br",
|
|
13
13
|
"documentation": "https://github.com/frinus-ai/frinus-mcp",
|
|
14
14
|
"support": "https://github.com/frinus-ai/frinus-mcp/issues",
|
|
15
|
-
"icon": "icon.png",
|
|
16
15
|
"server": {
|
|
17
16
|
"type": "node",
|
|
18
17
|
"entry_point": "dist/index.js",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "frinus-mcp",
|
|
3
|
-
"version": "3.1
|
|
3
|
+
"version": "3.2.1",
|
|
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",
|