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 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 |
@@ -39,6 +39,7 @@ export declare class MemoryClient implements MemoryClientInterface {
39
39
  metadata?: Record<string, unknown>;
40
40
  context_id?: string;
41
41
  credential_ref?: string;
42
+ universe_id?: string;
42
43
  }): Promise<any>;
43
44
  searchMemories(data: {
44
45
  query_text: string;
@@ -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: user (private), agent (agent-only), universe (department), organization (org-wide). Default: organization",
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: `Retrieve a decrypted credential from the vault.
1506
+ description: `Inspect a credential's NON-SECRET metadata — never its value.
1503
1507
 
1504
- Returns the decrypted credential data for the given reference.
1505
- Only the credential owner (authenticated user) can access their credentials.
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
- Use this when a memory has a credential_ref and you need the actual
1508
- credentials to perform an action (e.g., connect to a database, call an API).`,
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 retrieve (e.g., 'jira_muza')",
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.
@@ -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 createdByUser = args.created_by_user_id || resolvedUserId || undefined;
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",
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.0",
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",