@2kw/ai-mcp-server 6.3.0-dev.8 → 6.3.0-dev.84

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
@@ -42,6 +42,7 @@ Get an API key from your [2kw.ai dashboard](https://2kw.ai) — paid plans start
42
42
  |----------|----------|---------|-------------|
43
43
  | `AI_2KW_API_KEY` | Yes | — | API key for the 2kw.ai platform (legacy alias: `BACKBONE_API_KEY`) |
44
44
  | `AI_2KW_BASE_URL` | No | 2kw.ai cloud API | Point at a self-hosted deployment (legacy alias: `BACKBONE_BASE_URL`) |
45
+ | `AI_2KW_MEMORY` | No | off | `true` makes agent runs through `2kw_create_response` and `2kw_decide_agent_approvals` opt into agent memory (`X-Backbone-Memory: enabled`) |
45
46
  | `MCP_TRANSPORT` | No | `stdio` | Transport mode: `stdio` or `http` |
46
47
  | `MCP_HTTP_PORT` | No | `3100` | Port for HTTP transport |
47
48
 
@@ -56,10 +57,11 @@ Get an API key from your [2kw.ai dashboard](https://2kw.ai) — paid plans start
56
57
  | **Schemas** | CRUD, versioning, labels, validation, and testing against sample documents |
57
58
  | **Prompts** | Versioned prompt management, labels, compilation, testing |
58
59
  | **Datasets & experiments** | Build datasets, run experiments, manage evaluators, record scores |
59
- | **Agents** | CRUD, versioning, labels, tool-approval history, and tool-catalog sync history |
60
+ | **Agents** | CRUD, versioning, labels, runs with the full result envelope, approval decisions, tool-approval history, and tool-catalog sync history |
60
61
  | **Conversations** | Create/inspect/delete conversations and replay their items for the responses API |
61
62
  | **Knowledge base** | CRUD, document upload/attach/versioning, hybrid search, and citation resolution |
62
63
  | **Files** | Upload, list, download, and delete files (agent input or knowledge documents) |
64
+ | **Memory** | List, read, write and delete your own agent memory files, forget everything; admins see member totals and erase a member |
63
65
  | **Observability** | Analytics (spend, quality, providers, errors), traces, billing limits |
64
66
  | **API docs** | Browse the platform API documentation by section |
65
67
 
package/dist/client.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import createClient from "openapi-fetch";
1
+ import createClient, { type Middleware } from "openapi-fetch";
2
2
  import type { paths } from "./generated/openapi.js";
3
3
  export type ApiClient = ReturnType<typeof createClient<paths>> & {
4
4
  /** Exposed config for raw fetch calls (e.g. multipart uploads). */
@@ -7,8 +7,15 @@ export type ApiClient = ReturnType<typeof createClient<paths>> & {
7
7
  apiKey: string;
8
8
  };
9
9
  };
10
+ /** The per-request opt-in to agent memory for API-key runs (agent memory spec §3.3, D6, D7). */
11
+ export declare const MEMORY_HEADER = "X-Backbone-Memory";
12
+ /** Adds `X-Backbone-Memory: enabled` to `POST …/v1/responses` only, the call that starts or resumes a run. */
13
+ export declare const memoryHeaderMiddleware: Middleware;
10
14
  /**
11
15
  * Create a typed openapi-fetch client with Bearer auth and error middleware.
16
+ * `memory: true` opts agent runs into agent memory (see {@link memoryHeaderMiddleware}).
12
17
  */
13
- export declare function createApiClient(baseUrl: string, apiKey: string): ApiClient;
18
+ export declare function createApiClient(baseUrl: string, apiKey: string, options?: {
19
+ memory?: boolean;
20
+ }): ApiClient;
14
21
  //# sourceMappingURL=client.d.ts.map
package/dist/client.js CHANGED
@@ -34,10 +34,22 @@ const errorMiddleware = {
34
34
  });
35
35
  },
36
36
  };
37
+ /** The per-request opt-in to agent memory for API-key runs (agent memory spec §3.3, D6, D7). */
38
+ export const MEMORY_HEADER = "X-Backbone-Memory";
39
+ /** Adds `X-Backbone-Memory: enabled` to `POST …/v1/responses` only, the call that starts or resumes a run. */
40
+ export const memoryHeaderMiddleware = {
41
+ onRequest({ request }) {
42
+ if (request.method === "POST" && new URL(request.url).pathname.endsWith("/v1/responses")) {
43
+ request.headers.set(MEMORY_HEADER, "enabled");
44
+ }
45
+ return request;
46
+ },
47
+ };
37
48
  /**
38
49
  * Create a typed openapi-fetch client with Bearer auth and error middleware.
50
+ * `memory: true` opts agent runs into agent memory (see {@link memoryHeaderMiddleware}).
39
51
  */
40
- export function createApiClient(baseUrl, apiKey) {
52
+ export function createApiClient(baseUrl, apiKey, options = {}) {
41
53
  const client = createClient({
42
54
  baseUrl: baseUrl.replace(/\/+$/, ""),
43
55
  headers: {
@@ -45,6 +57,8 @@ export function createApiClient(baseUrl, apiKey) {
45
57
  },
46
58
  });
47
59
  client.use(errorMiddleware);
60
+ if (options.memory)
61
+ client.use(memoryHeaderMiddleware);
48
62
  // Expose config for raw fetch calls (multipart uploads)
49
63
  const enriched = client;
50
64
  enriched._config = { baseUrl: baseUrl.replace(/\/+$/, ""), apiKey };
package/dist/index.js CHANGED
@@ -31,6 +31,7 @@ import * as agents from "./tools/agents.js";
31
31
  import * as conversations from "./tools/conversations.js";
32
32
  import * as knowledge from "./tools/knowledge.js";
33
33
  import * as files from "./tools/files.js";
34
+ import * as memory from "./tools/memory.js";
34
35
  // Read the canonical KW_* env var; fall back to legacy BACKBONE_* for
35
36
  // backwards compatibility. Warn once on stderr when the legacy name supplied
36
37
  // the value.
@@ -67,7 +68,8 @@ async function main() {
67
68
  const baseUrl = readEnv("AI_2KW_BASE_URL", "BACKBONE_BASE_URL") ?? DEFAULT_BASE_URL;
68
69
  const transport = process.env.MCP_TRANSPORT ?? "stdio";
69
70
  const httpPort = parseInt(process.env.MCP_HTTP_PORT ?? "3100", 10);
70
- const client = createApiClient(baseUrl, apiKey);
71
+ // Opt API-key runs into agent memory (X-Backbone-Memory: enabled on responses calls, #721).
72
+ const client = createApiClient(baseUrl, apiKey, { memory: process.env.AI_2KW_MEMORY === "true" });
71
73
  const server = new McpServer({
72
74
  name: "2kw",
73
75
  version: pkg.version,
@@ -98,6 +100,7 @@ async function main() {
98
100
  conversations.register(server, client);
99
101
  knowledge.register(server, client);
100
102
  files.register(server, client);
103
+ memory.register(server, client);
101
104
  if (transport === "http") {
102
105
  const httpTransport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
103
106
  const httpServer = createServer(async (req, res) => {
@@ -0,0 +1,54 @@
1
+ import type { ApiClient } from "../client.js";
2
+ import type { components } from "../generated/openapi.js";
3
+ import { type ConversationMode, type ResponsesResult } from "./agent-run.js";
4
+ /**
5
+ * Decide the approvals a paused agent run is waiting for (#667; spec 2026-09-14-cli-agents-design.md
6
+ * §4.2, D10, D11). Mirrors `cli/src/lib/agent-decide.ts` and `agent-lookup.ts`, with a per-approval
7
+ * input instead of the CLI's flags.
8
+ */
9
+ export type ApprovalRow = components["schemas"]["ToolApprovalDTO"];
10
+ export interface DecisionInput {
11
+ approvalId: string;
12
+ decision: "approve" | "reject";
13
+ reason?: string;
14
+ remember?: boolean;
15
+ }
16
+ export interface DecideInput {
17
+ decisions?: DecisionInput[];
18
+ decideAll?: "approve" | "reject";
19
+ /** Only with `decideAll`: the reason stored on every decision. */
20
+ reason?: string;
21
+ /** Only with `decideAll`: remember every approval for the conversation. */
22
+ remember?: boolean;
23
+ }
24
+ /** The #660 wire item, byte for byte what n8n Decide Approval and `bb agents decide` send. */
25
+ export interface ApprovalItem {
26
+ type: "backbone:approval_response";
27
+ approval_id: string;
28
+ decision: "approve" | "reject";
29
+ hmac: string;
30
+ reason?: string;
31
+ remember?: "conversation";
32
+ }
33
+ /** `name[@label][#model]`, split like the server: the model at the last `#`, then the label at the last `@`. */
34
+ export declare function splitAgentRef(ref: string): {
35
+ name: string;
36
+ label: string | undefined;
37
+ model: string | undefined;
38
+ };
39
+ /** A UUID is taken as the id; a name is matched exactly (the REST API only offers a substring `search`). */
40
+ export declare function resolveAgentId(client: ApiClient, name: string): Promise<string>;
41
+ export declare function fetchPendingApprovals(client: ApiClient, agentId: string, responseId: string): Promise<ApprovalRow[]>;
42
+ /**
43
+ * The backend refuses a continuation that leaves any pending approval of the paused response
44
+ * undecided (400 incomplete_tool_outputs, D10), so the whole set is checked here before sending.
45
+ */
46
+ export declare function planDecisions(pending: ApprovalRow[], input: DecideInput, responseId: string): ApprovalItem[];
47
+ /**
48
+ * Continues on `agent/<agentId>[@<label>][#<model>]`: the server resolves the continuation's tools,
49
+ * instructions and model from this reference, so label and model must be the run's (D11). Never the
50
+ * echoed `name@<versionNumber>` — it would 404 as a label.
51
+ * With `mode`, the `backbone:mode` item follows the decisions (#656).
52
+ */
53
+ export declare function continueWithDecisions(client: ApiClient, agentId: string, responseId: string, items: ApprovalItem[], label?: string, model?: string, mode?: ConversationMode): Promise<ResponsesResult>;
54
+ //# sourceMappingURL=agent-decide.d.ts.map
@@ -0,0 +1,124 @@
1
+ import { modeItem } from "./agent-run.js";
2
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
3
+ const PAGE_SIZE = 100;
4
+ const MAX_PAGES = 50;
5
+ /** `name[@label][#model]`, split like the server: the model at the last `#`, then the label at the last `@`. */
6
+ export function splitAgentRef(ref) {
7
+ const hash = ref.lastIndexOf("#");
8
+ const model = hash >= 0 ? ref.slice(hash + 1) : undefined;
9
+ const base = hash >= 0 ? ref.slice(0, hash) : ref;
10
+ const at = base.lastIndexOf("@");
11
+ if (at <= 0)
12
+ return { name: base, label: undefined, model };
13
+ return { name: base.slice(0, at), label: base.slice(at + 1), model };
14
+ }
15
+ /** A UUID is taken as the id; a name is matched exactly (the REST API only offers a substring `search`). */
16
+ export async function resolveAgentId(client, name) {
17
+ if (UUID.test(name))
18
+ return name;
19
+ for (let page = 0; page < MAX_PAGES; page++) {
20
+ const { data } = await client.GET("/v1/agents", {
21
+ // A stable sort: offset paging over an unordered result can skip rows between pages.
22
+ params: { query: { search: name, pageable: { page, size: PAGE_SIZE, sort: ["id,asc"] } } },
23
+ });
24
+ // Typed loosely, as 2kw_list_agents does: the generated page type of this endpoint is not a PageAgentDTO.
25
+ const pageData = data;
26
+ const content = pageData?.content ?? [];
27
+ const hit = content.find((a) => a.name === name);
28
+ if (hit?.id)
29
+ return String(hit.id);
30
+ if (pageData?.last !== false || content.length === 0)
31
+ break;
32
+ }
33
+ throw new Error(`Agent '${name}' not found. List agents with 2kw_list_agents.`);
34
+ }
35
+ export async function fetchPendingApprovals(client, agentId, responseId) {
36
+ const rows = [];
37
+ for (let page = 0; page < MAX_PAGES; page++) {
38
+ const { data } = await client.GET("/v1/agents/{agentId}/approvals", {
39
+ params: { path: { agentId }, query: { status: "pending", pageable: { page, size: PAGE_SIZE } } },
40
+ });
41
+ const content = data?.content ?? [];
42
+ rows.push(...content.filter((r) => r.responseId === responseId));
43
+ if (data?.last !== false || content.length === 0)
44
+ break;
45
+ }
46
+ return rows;
47
+ }
48
+ /**
49
+ * The backend refuses a continuation that leaves any pending approval of the paused response
50
+ * undecided (400 incomplete_tool_outputs, D10), so the whole set is checked here before sending.
51
+ */
52
+ export function planDecisions(pending, input, responseId) {
53
+ if (pending.length === 0) {
54
+ throw new Error(`No pending approvals for ${responseId} (already decided or superseded).`);
55
+ }
56
+ const hasList = input.decisions !== undefined && input.decisions.length > 0;
57
+ if (hasList === (input.decideAll !== undefined)) {
58
+ throw new Error("Give either `decisions` or `decideAll` (exactly one): one decision per pending approval, or one for all.");
59
+ }
60
+ const chosen = new Map();
61
+ if (input.decideAll !== undefined) {
62
+ for (const r of pending) {
63
+ chosen.set(String(r.id), { approvalId: String(r.id), decision: input.decideAll, reason: input.reason, remember: input.remember });
64
+ }
65
+ }
66
+ else {
67
+ const twice = new Set();
68
+ for (const d of input.decisions) {
69
+ if (chosen.has(d.approvalId))
70
+ twice.add(d.approvalId);
71
+ chosen.set(d.approvalId, d);
72
+ }
73
+ if (twice.size)
74
+ throw new Error(`Approvals decided more than once: ${[...twice].join(", ")}`);
75
+ const pendingIds = new Set(pending.map((r) => String(r.id)));
76
+ const foreign = [...chosen.keys()].filter((id) => !pendingIds.has(id));
77
+ if (foreign.length)
78
+ throw new Error(`Not pending on ${responseId}: ${foreign.join(", ")}`);
79
+ const undecided = [...pendingIds].filter((id) => !chosen.has(id));
80
+ if (undecided.length) {
81
+ throw new Error(`Every pending approval of a response must be decided in one call. Undecided: ${undecided.join(", ")}`);
82
+ }
83
+ }
84
+ return pending.map((r) => {
85
+ const id = String(r.id);
86
+ const d = chosen.get(id);
87
+ if (d.remember && d.decision !== "approve") {
88
+ throw new Error(`\`remember\` applies only to an approve: ${id}`);
89
+ }
90
+ if (d.remember && (r.policyClass ?? "").toUpperCase() === "DESTRUCTIVE") {
91
+ throw new Error(`\`remember\` cannot be used on a destructive tool call: ${id}`);
92
+ }
93
+ if (!r.hmac)
94
+ throw new Error(`Approval ${id} carries no hmac and cannot be decided here.`);
95
+ return {
96
+ type: "backbone:approval_response",
97
+ approval_id: id,
98
+ decision: d.decision,
99
+ hmac: r.hmac,
100
+ ...(d.reason ? { reason: d.reason } : {}),
101
+ ...(d.remember ? { remember: "conversation" } : {}),
102
+ };
103
+ });
104
+ }
105
+ /**
106
+ * Continues on `agent/<agentId>[@<label>][#<model>]`: the server resolves the continuation's tools,
107
+ * instructions and model from this reference, so label and model must be the run's (D11). Never the
108
+ * echoed `name@<versionNumber>` — it would 404 as a label.
109
+ * With `mode`, the `backbone:mode` item follows the decisions (#656).
110
+ */
111
+ export async function continueWithDecisions(client, agentId, responseId, items, label, model, mode) {
112
+ const body = {
113
+ model: `agent/${agentId}${label ? `@${label}` : ""}${model ? `#${model}` : ""}`,
114
+ previous_response_id: responseId,
115
+ stream: false,
116
+ // With `mode`, the backbone:mode item follows the decisions (#656 D7); without it, nothing is added (D2).
117
+ input: mode ? [...items, modeItem(mode)] : items,
118
+ };
119
+ // The generated body type does not model backbone: extension items, hence the cast.
120
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
121
+ const { data } = await client.POST("/v1/responses", { body });
122
+ return data;
123
+ }
124
+ //# sourceMappingURL=agent-decide.js.map
@@ -0,0 +1,124 @@
1
+ /**
2
+ * The run envelope of `bb agents run --json` (spec 2026-09-14-cli-agents-design.md §4.3), copied
3
+ * from `cli/src/lib/agent-run.ts` because cli/ and mcp/ share no package (#667). Keep the two in step;
4
+ * only the text of `next` differs, because an MCP caller decides through a tool, not a shell command.
5
+ */
6
+ type AnyRecord = Record<string, any>;
7
+ export type RunStatus = "completed" | "requires_approval" | "requires_tool_output" | "incomplete";
8
+ export interface PendingApproval {
9
+ approvalId: string;
10
+ callId: string;
11
+ tool: string;
12
+ arguments: unknown;
13
+ policyClass: string;
14
+ /** Why the automatic approver escalated this request to a human (#634); null when it did not. */
15
+ reason: string | null;
16
+ }
17
+ export interface ToolCallSummary {
18
+ tool: string;
19
+ callId: string;
20
+ /** `incomplete` when the server-side tool run failed (its `function_call_output` carries that status). */
21
+ status: "completed" | "incomplete";
22
+ }
23
+ export interface PendingToolCall {
24
+ tool: string;
25
+ callId: string;
26
+ arguments: unknown;
27
+ }
28
+ /**
29
+ * A connector the run waits for the user to connect, allow or reconnect in chat.2kw.ai: an open
30
+ * `backbone:connector_auth_request` (#807 R12). The caller never answers the connect call; the
31
+ * continuation re-checks access itself.
32
+ */
33
+ export interface PendingConnection {
34
+ serverLabel: string;
35
+ host: string;
36
+ /** `connect`, `allow` or `reconnect`. */
37
+ reason: string;
38
+ /** The egress difference an allow is re-asked for; absent otherwise. */
39
+ destinations?: string[];
40
+ }
41
+ /** Fallback chat.2kw.ai origin when the API host is not one we recognise. */
42
+ export declare const DEFAULT_CHAT_URL = "https://chat.2kw.ai";
43
+ /**
44
+ * The chat web host that belongs to the server's API base URL (`AI_2KW_BASE_URL`), where a member
45
+ * connects a connector. `AI_2KW_CHAT_URL` overrides the mapping; a trailing slash is dropped.
46
+ */
47
+ export declare function chatUrlFor(baseUrl: string | undefined, env?: NodeJS.ProcessEnv): string;
48
+ /** The end user's conversation mode (epic &59); the server matches these three values exactly. */
49
+ export type ConversationMode = "plan" | "ask" | "auto";
50
+ export declare const CONVERSATION_MODES: readonly ConversationMode[];
51
+ /**
52
+ * Who may change the mode (#656 D8), stated in both tool descriptions: the mode outlives the request
53
+ * that sets it, so a model that switched on its own would lift the user's choice for every later turn.
54
+ */
55
+ export declare const MODE_RULE: string;
56
+ /** The `backbone:mode` input item (S1 D7); always appended last. */
57
+ export declare function modeItem(mode: ConversationMode): {
58
+ type: "backbone:mode";
59
+ mode: ConversationMode;
60
+ };
61
+ /**
62
+ * The mode the response ran under (`conversation_mode`, #656). An absent key (a server before #656)
63
+ * and any value other than the three read as null, which also means "none set".
64
+ */
65
+ export declare function responseMode(result: ResponsesResult | undefined): ConversationMode | null;
66
+ export interface RunEnvelope {
67
+ status: RunStatus;
68
+ /** The conversation mode the request ran under (`conversation_mode`); null when none is set. */
69
+ mode: ConversationMode | null;
70
+ agent: string | null;
71
+ version: number | null;
72
+ responseId: string | null;
73
+ conversationId: string | null;
74
+ text: string;
75
+ toolCalls: ToolCallSummary[];
76
+ pendingApprovals: PendingApproval[];
77
+ pendingToolCalls: PendingToolCall[];
78
+ pendingConnections: PendingConnection[];
79
+ incompleteReason: string | null;
80
+ usage: {
81
+ inputTokens: number;
82
+ outputTokens: number;
83
+ } | null;
84
+ next: string | null;
85
+ }
86
+ /** Loose wire shape of a `POST /v1/responses` result; the OpenAPI spec types it as `object`. */
87
+ export interface ResponsesResult {
88
+ id?: string;
89
+ status?: string;
90
+ model?: string;
91
+ output?: AnyRecord[];
92
+ usage?: {
93
+ input_tokens?: number;
94
+ output_tokens?: number;
95
+ total_tokens?: number;
96
+ };
97
+ conversation?: {
98
+ id?: string;
99
+ } | null;
100
+ incomplete_details?: {
101
+ reason?: string;
102
+ } | null;
103
+ conversation_mode?: string | null;
104
+ }
105
+ /** The assistant's text: every `output_text` part of every `message` item, in order. */
106
+ export declare function extractResponseText(result: ResponsesResult | undefined): string;
107
+ /**
108
+ * The response echoes `model` as `agent/{name}@{versionNumber}` (all digits), optionally
109
+ * followed by a `#<model>` override. Only a trailing all-digit `@N` is read as a version;
110
+ * any other `@` suffix stays part of the name.
111
+ */
112
+ export declare function parseAgentModel(model?: string): {
113
+ agent: string | null;
114
+ version: number | null;
115
+ };
116
+ /**
117
+ * @param agentRef the reference the run was started with (`name[@label][#model]`); `next` names it so
118
+ * the decision continues on the same label and model (D11). Falls back to the echoed agent name.
119
+ * @param chatUrl the chat web host of the API ({@link chatUrlFor}); a connect pause's `next` names its
120
+ * Connectors page.
121
+ */
122
+ export declare function buildRunEnvelope(result: ResponsesResult | undefined, agentRef?: string, chatUrl?: string): RunEnvelope;
123
+ export {};
124
+ //# sourceMappingURL=agent-run.d.ts.map
@@ -0,0 +1,260 @@
1
+ /**
2
+ * The run envelope of `bb agents run --json` (spec 2026-09-14-cli-agents-design.md §4.3), copied
3
+ * from `cli/src/lib/agent-run.ts` because cli/ and mcp/ share no package (#667). Keep the two in step;
4
+ * only the text of `next` differs, because an MCP caller decides through a tool, not a shell command.
5
+ */
6
+ /** Fallback chat.2kw.ai origin when the API host is not one we recognise. */
7
+ export const DEFAULT_CHAT_URL = "https://chat.2kw.ai";
8
+ /** Known API-host → chat web host mappings, as the CLI's `CHAT_URL_BY_API_HOST` (#807 R11). */
9
+ const CHAT_URL_BY_API_HOST = {
10
+ "api.2kw.ai": "https://chat.2kw.ai",
11
+ "api-dev.2kw.ai": "https://chat-dev.2kw.ai",
12
+ "backbone.manfred-kunze.dev": "https://chat.2kw.ai",
13
+ "localhost:8080": "http://localhost:3000",
14
+ "127.0.0.1:8080": "http://localhost:3000",
15
+ };
16
+ /**
17
+ * The chat web host that belongs to the server's API base URL (`AI_2KW_BASE_URL`), where a member
18
+ * connects a connector. `AI_2KW_CHAT_URL` overrides the mapping; a trailing slash is dropped.
19
+ */
20
+ export function chatUrlFor(baseUrl, env = process.env) {
21
+ const override = env.AI_2KW_CHAT_URL?.trim();
22
+ if (override)
23
+ return override.replace(/\/+$/, "");
24
+ try {
25
+ const host = new URL(baseUrl ?? "").host;
26
+ // hasOwn: a host named after an Object.prototype key must not resolve.
27
+ return Object.hasOwn(CHAT_URL_BY_API_HOST, host) ? CHAT_URL_BY_API_HOST[host] : DEFAULT_CHAT_URL;
28
+ }
29
+ catch {
30
+ return DEFAULT_CHAT_URL;
31
+ }
32
+ }
33
+ export const CONVERSATION_MODES = ["plan", "ask", "auto"];
34
+ /**
35
+ * Who may change the mode (#656 D8), stated in both tool descriptions: the mode outlives the request
36
+ * that sets it, so a model that switched on its own would lift the user's choice for every later turn.
37
+ */
38
+ export const MODE_RULE = "Set `mode` only when the user explicitly asks for that mode. Never set it to retry a refused approval " +
39
+ "or to leave plan mode on your own: ask the user first. plan = read-only; ask = no automatic approver, " +
40
+ "calls that need approval wait for a person; auto = the operator's policy as written. Leave it out to " +
41
+ "keep the conversation's mode.";
42
+ function isConversationMode(value) {
43
+ return typeof value === "string" && CONVERSATION_MODES.includes(value);
44
+ }
45
+ /** The `backbone:mode` input item (S1 D7); always appended last. */
46
+ export function modeItem(mode) {
47
+ return { type: "backbone:mode", mode };
48
+ }
49
+ /**
50
+ * The mode the response ran under (`conversation_mode`, #656). An absent key (a server before #656)
51
+ * and any value other than the three read as null, which also means "none set".
52
+ */
53
+ export function responseMode(result) {
54
+ const raw = result?.conversation_mode;
55
+ return isConversationMode(raw) ? raw : null;
56
+ }
57
+ /** The assistant's text: every `output_text` part of every `message` item, in order. */
58
+ export function extractResponseText(result) {
59
+ const parts = [];
60
+ for (const item of result?.output ?? []) {
61
+ if (item.type !== "message")
62
+ continue;
63
+ for (const part of item.content ?? []) {
64
+ if (part.type === "output_text" && part.text)
65
+ parts.push(part.text);
66
+ }
67
+ }
68
+ return parts.join("\n");
69
+ }
70
+ /**
71
+ * The response echoes `model` as `agent/{name}@{versionNumber}` (all digits), optionally
72
+ * followed by a `#<model>` override. Only a trailing all-digit `@N` is read as a version;
73
+ * any other `@` suffix stays part of the name.
74
+ */
75
+ export function parseAgentModel(model) {
76
+ // Strip the `#<model>` override (from the last `#`, as the server splits it) before matching.
77
+ const hash = (model ?? "").lastIndexOf("#");
78
+ const ref = hash >= 0 ? (model ?? "").slice(0, hash) : model ?? "";
79
+ const withVersion = /^agent\/(.+)@(\d+)$/.exec(ref);
80
+ if (withVersion)
81
+ return { agent: withVersion[1], version: Number(withVersion[2]) };
82
+ const bare = /^agent\/(.+)$/.exec(ref);
83
+ if (bare)
84
+ return { agent: bare[1], version: null };
85
+ return { agent: null, version: null };
86
+ }
87
+ function parseArguments(raw) {
88
+ if (typeof raw !== "string")
89
+ return raw ?? {};
90
+ try {
91
+ return JSON.parse(raw);
92
+ }
93
+ catch {
94
+ return raw;
95
+ }
96
+ }
97
+ /** An open connector consent request; a continuation's projection carries another status. */
98
+ function isOpenConnectRequest(item) {
99
+ return item.type === "backbone:connector_auth_request" && (item.status === undefined || item.status === "in_progress");
100
+ }
101
+ function pendingConnectionsOf(output) {
102
+ return output.filter(isOpenConnectRequest).map((i) => ({
103
+ serverLabel: String(i.server_label),
104
+ host: String(i.host),
105
+ reason: String(i.reason),
106
+ ...(Array.isArray(i.destinations) && i.destinations.length > 0
107
+ ? { destinations: i.destinations.map((d) => String(d)) }
108
+ : {}),
109
+ }));
110
+ }
111
+ /**
112
+ * The call ids a connect pause withholds from the caller: the call each request names, and every
113
+ * `mcp__<label>__connect` call for a pending connector (one request stands for all of them).
114
+ */
115
+ function connectCallIds(output, connections) {
116
+ const connectNames = new Set(connections.map((c) => `mcp__${c.serverLabel}__connect`));
117
+ const ids = new Set();
118
+ for (const item of output) {
119
+ if (isOpenConnectRequest(item) || (item.type === "function_call" && connectNames.has(String(item.name)))) {
120
+ ids.add(String(item.call_id));
121
+ }
122
+ }
123
+ return ids;
124
+ }
125
+ function connectionPhrase(c) {
126
+ const target = `${c.serverLabel} (${c.host})`;
127
+ if (c.reason === "allow")
128
+ return `allow the agent to use ${target}`;
129
+ if (c.reason === "reconnect")
130
+ return `reconnect ${target}`;
131
+ return `connect ${target}`;
132
+ }
133
+ function connectionsPhrase(connections) {
134
+ const phrases = connections.map(connectionPhrase);
135
+ return phrases.length > 1 ? `${phrases.slice(0, -1).join(", ")} and ${phrases.at(-1)}` : phrases[0] ?? "";
136
+ }
137
+ /**
138
+ * Output item types the envelope decodes, or that never hold a run: text, reasoning and finished
139
+ * connector calls. A pause on anything else (a connector approval's `mcp_approval_request`) is named.
140
+ */
141
+ const DECODED_ITEM_TYPES = new Set([
142
+ "message",
143
+ "reasoning",
144
+ "function_call",
145
+ "function_call_output",
146
+ "backbone:approval_request",
147
+ "backbone:connector_auth_request",
148
+ "mcp_call",
149
+ "mcp_list_tools",
150
+ ]);
151
+ function undecodedPause(output) {
152
+ const types = [...new Set(output.map((i) => String(i.type)).filter((t) => !DECODED_ITEM_TYPES.has(t)))];
153
+ return types.length > 0
154
+ ? `This server cannot show or answer ${types.join(", ")}.`
155
+ : "This server cannot tell what the run waits for.";
156
+ }
157
+ /**
158
+ * @param agentRef the reference the run was started with (`name[@label][#model]`); `next` names it so
159
+ * the decision continues on the same label and model (D11). Falls back to the echoed agent name.
160
+ * @param chatUrl the chat web host of the API ({@link chatUrlFor}); a connect pause's `next` names its
161
+ * Connectors page.
162
+ */
163
+ export function buildRunEnvelope(result, agentRef, chatUrl = DEFAULT_CHAT_URL) {
164
+ const output = result?.output ?? [];
165
+ const { agent, version } = parseAgentModel(result?.model);
166
+ // call_id → status of its tool output; a failed server-side tool run is marked `incomplete`.
167
+ const outputStatuses = new Map(output
168
+ .filter((i) => i.type === "function_call_output")
169
+ .map((i) => [
170
+ String(i.call_id),
171
+ i.status === "incomplete" ? "incomplete" : "completed",
172
+ ]));
173
+ const pendingApprovals = output
174
+ // Only an open request is pending; judge- or grant-decided requests (#634, #629) carry another status.
175
+ .filter((i) => i.type === "backbone:approval_request" && (i.status === undefined || i.status === "in_progress"))
176
+ .map((i) => ({
177
+ approvalId: String(i.id),
178
+ callId: String(i.call_id),
179
+ tool: String(i.tool),
180
+ arguments: parseArguments(i.arguments),
181
+ policyClass: String(i.policy_class),
182
+ reason: typeof i.reason === "string" && i.reason ? i.reason : null,
183
+ }));
184
+ const pendingConnections = pendingConnectionsOf(output);
185
+ const withheldCallIds = new Set([...pendingApprovals.map((a) => a.callId), ...connectCallIds(output, pendingConnections)]);
186
+ const toolCalls = [];
187
+ const pendingToolCalls = [];
188
+ for (const item of output) {
189
+ if (item.type !== "function_call")
190
+ continue;
191
+ const callId = String(item.call_id);
192
+ const outputStatus = outputStatuses.get(callId);
193
+ if (outputStatus) {
194
+ toolCalls.push({ tool: String(item.name), callId, status: outputStatus });
195
+ }
196
+ else if (!withheldCallIds.has(callId)) {
197
+ pendingToolCalls.push({ tool: String(item.name), callId, arguments: parseArguments(item.arguments) });
198
+ }
199
+ }
200
+ let status;
201
+ switch (result?.status) {
202
+ case "completed":
203
+ status = "completed";
204
+ break;
205
+ case "incomplete":
206
+ status = "incomplete";
207
+ break;
208
+ case "requires_action":
209
+ // A connect pause is requires_tool_output too (#807 R12): no tool here can answer it.
210
+ status =
211
+ pendingToolCalls.length > 0 || pendingConnections.length > 0 || pendingApprovals.length === 0
212
+ ? "requires_tool_output"
213
+ : "requires_approval";
214
+ break;
215
+ default:
216
+ throw new Error(`Unexpected response status: ${result?.status}`);
217
+ }
218
+ const responseId = result?.id ?? null;
219
+ const ref = agentRef ?? agent;
220
+ let next = null;
221
+ if (status === "requires_approval" && ref && responseId) {
222
+ // Never an "approve all" hint: the assistant decides only what the user decided (D12).
223
+ next = `Show the pending approvals to the user; after they decide each one, call 2kw_decide_agent_approvals with agent ${JSON.stringify(ref)} and responseId ${JSON.stringify(responseId)}.`;
224
+ }
225
+ else if (status === "requires_tool_output" && pendingConnections.length > 0 && pendingToolCalls.length === 0 && responseId) {
226
+ // No tool here takes previous_response_id, and a connect pause cannot be continued by
227
+ // conversation (the gateway answers 400), so the honest ways on are a fresh request or a
228
+ // client that continues by response id.
229
+ next =
230
+ `Tell the user to ${connectionsPhrase(pendingConnections)} in ${chatUrl}/connectors. ` +
231
+ `This server cannot continue response ${JSON.stringify(responseId)}: once they have connected, send the request again ` +
232
+ "with 2kw_create_response without `conversation` (a connect pause cannot be continued by conversation), " +
233
+ "or continue the response by its id from n8n (Previous Response ID) or any client that sends previous_response_id.";
234
+ }
235
+ else if (status === "requires_tool_output" && pendingConnections.length === 0 && pendingToolCalls.length === 0 && responseId) {
236
+ // Paused on nothing the envelope decodes: say so, rather than hand back a pause with no way on.
237
+ next =
238
+ `${undecodedPause(output)} Response ${JSON.stringify(responseId)} stays paused: tell the user to answer it ` +
239
+ `in ${chatUrl}/, or continue it from a client that sends previous_response_id.`;
240
+ }
241
+ return {
242
+ status,
243
+ mode: responseMode(result),
244
+ agent,
245
+ version,
246
+ responseId,
247
+ conversationId: result?.conversation?.id ?? null,
248
+ text: extractResponseText(result),
249
+ toolCalls,
250
+ pendingApprovals,
251
+ pendingToolCalls,
252
+ pendingConnections,
253
+ incompleteReason: result?.incomplete_details?.reason ?? null,
254
+ usage: result?.usage
255
+ ? { inputTokens: result.usage.input_tokens ?? 0, outputTokens: result.usage.output_tokens ?? 0 }
256
+ : null,
257
+ next,
258
+ };
259
+ }
260
+ //# sourceMappingURL=agent-run.js.map
@@ -1,5 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { formatErrorForMcp } from "../errors.js";
3
+ import { continueWithDecisions, fetchPendingApprovals, planDecisions, resolveAgentId, splitAgentRef } from "../lib/agent-decide.js";
4
+ import { buildRunEnvelope, chatUrlFor, MODE_RULE } from "../lib/agent-run.js";
3
5
  const modelSchema = z
4
6
  .string()
5
7
  .min(1)
@@ -301,6 +303,30 @@ export function register(server, client) {
301
303
  };
302
304
  }
303
305
  });
306
+ // ── list_agent_skills ───────────────────────────────────────────────────
307
+ server.tool("2kw_list_agent_skills", "List the skills an agent's latest published version binds, in authored order. This is the "
308
+ + "read a chat-only USER key may perform; 2kw_get_latest_agent_version carries the full version "
309
+ + "and needs VIEWER or above. An agent with no published version lists none.", { agentId: z.string().describe("The agent ID") }, async ({ agentId }) => {
310
+ try {
311
+ const { data } = await client.GET("/v1/agents/{agentId}/skills", {
312
+ params: { path: { agentId } },
313
+ });
314
+ const lines = (data ?? []).map((s) => {
315
+ const plugin = s.pluginName ? `, plugin: ${s.pluginName}` : "";
316
+ const description = s.description ? ` — ${s.description}` : "";
317
+ return `- ${s.name} (v${s.versionNumber}, ref: ${s.ref}${plugin})${description}`;
318
+ });
319
+ return {
320
+ content: [{ type: "text", text: `Agent skills:\n${lines.join("\n") || "(none)"}` }],
321
+ };
322
+ }
323
+ catch (error) {
324
+ return {
325
+ content: [{ type: "text", text: formatErrorForMcp(error) }],
326
+ isError: true,
327
+ };
328
+ }
329
+ });
304
330
  // ── get_agent_version ───────────────────────────────────────────────────
305
331
  server.tool("2kw_get_agent_version", "Retrieve a specific version of an agent.", {
306
332
  agentId: z.string().describe("The agent ID"),
@@ -333,12 +359,16 @@ export function register(server, client) {
333
359
  .string()
334
360
  .optional()
335
361
  .describe("Resolve this installation's module tool catalog, the way a run of that installation would. Omit to answer for the agent's own tools only."),
336
- }, async ({ agentId, versionId, tool, installationId }) => {
362
+ mode: z
363
+ .enum(["plan", "ask", "auto"])
364
+ .optional()
365
+ .describe("Answer as a conversation in this mode would be gated: plan refuses every call that is not read-only, ask puts a person where the policy would ask the judge, auto is the policy as written. A mode that contributed is listed in matchedRules as conversation_mode.<mode>. Omit for no mode."),
366
+ }, async ({ agentId, versionId, tool, installationId, mode }) => {
337
367
  try {
338
368
  const { data } = await client.GET("/v1/agents/{agentId}/versions/{versionId}/policy", {
339
369
  params: {
340
370
  path: { agentId, versionId },
341
- query: { tool, installation: installationId },
371
+ query: { tool, installation: installationId, mode },
342
372
  },
343
373
  });
344
374
  return {
@@ -503,6 +533,51 @@ export function register(server, client) {
503
533
  };
504
534
  }
505
535
  });
536
+ // ── decide_agent_approvals ──────────────────────────────────────────────
537
+ server.tool("2kw_decide_agent_approvals", "Answer the tool approvals a paused agent run is waiting for (a 2kw_create_response envelope with status requires_approval), then continue the run. "
538
+ + "Decide only what the user decided: show them each pending approval (tool, arguments, policy class) first and never approve on your own. "
539
+ + "Every pending approval of the response must be decided in this one call. Pass `agent` exactly as the run was started (keep '@label' and '#model'). "
540
+ + "Returns the continuation's run envelope, which can pause again: for approval, or on a connector the user must connect "
541
+ + "in chat.2kw.ai → Connectors (`pendingConnections`; follow its `next`)."
542
+ + " " + MODE_RULE, {
543
+ agent: z.string().min(1).describe("Agent id or name as the run used it: 'ref[@label][#model]'"),
544
+ responseId: z.string().min(1).describe("The paused response's id (envelope `responseId`)"),
545
+ decisions: z
546
+ .array(z.object({
547
+ approvalId: z.string().min(1).describe("Envelope `pendingApprovals[].approvalId`"),
548
+ decision: z.enum(["approve", "reject"]),
549
+ reason: z.string().optional().describe("Stored with the decision; the model sees it on a reject"),
550
+ remember: z.boolean().optional().describe("Approve this tool for the rest of the conversation; not on destructive tools"),
551
+ }))
552
+ .optional()
553
+ .describe("One entry per pending approval. Mutually exclusive with `decideAll`."),
554
+ decideAll: z.enum(["approve", "reject"]).optional().describe("Decide every pending approval the same way"),
555
+ reason: z.string().optional().describe("Only with `decideAll`: reason stored on every decision"),
556
+ remember: z.boolean().optional().describe("Only with `decideAll`: remember every approval for the conversation"),
557
+ mode: z
558
+ .enum(["plan", "ask", "auto"])
559
+ .optional()
560
+ .describe("The conversation mode this continuation and later turns run under. Only on the user's explicit request."),
561
+ }, async ({ agent, responseId, decisions, decideAll, reason, remember, mode }) => {
562
+ try {
563
+ if (decisions !== undefined && (reason !== undefined || remember !== undefined)) {
564
+ throw new Error("With `decisions`, give `reason` and `remember` per entry.");
565
+ }
566
+ const { name, label, model } = splitAgentRef(agent);
567
+ const agentId = await resolveAgentId(client, name);
568
+ const pending = await fetchPendingApprovals(client, agentId, responseId);
569
+ const items = planDecisions(pending, { decisions, decideAll, reason, remember }, responseId);
570
+ const result = await continueWithDecisions(client, agentId, responseId, items, label, model, mode);
571
+ const envelope = buildRunEnvelope(result, agent, chatUrlFor(client._config?.baseUrl));
572
+ return { content: [{ type: "text", text: JSON.stringify(envelope, null, 2) }] };
573
+ }
574
+ catch (error) {
575
+ return {
576
+ content: [{ type: "text", text: formatErrorForMcp(error) }],
577
+ isError: true,
578
+ };
579
+ }
580
+ });
506
581
  // ── list_agent_tool_catalogs ─────────────────────────────────────────────
507
582
  server.tool("2kw_list_agent_tool_catalogs", "List an agent's tool-catalog sync history, newest first. Optionally narrow to one installation. The signed bytes and their signature are never returned.", {
508
583
  agentId: z.string().describe("The agent ID"),
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { formatErrorForMcp } from "../errors.js";
3
+ import { buildRunEnvelope, chatUrlFor, MODE_RULE, modeItem } from "../lib/agent-run.js";
3
4
  /**
4
5
  * The `model` value for an agent run: `agent/<ref>[@<label>]`, plus `#<model>` when the
5
6
  * request switches to another entry of the version's `models` list (#626, spec #591 §4.1).
@@ -92,7 +93,7 @@ export function register(server, client) {
92
93
  }
93
94
  });
94
95
  // ── create_response ─────────────────────────────────────────────────────
95
- server.tool("2kw_create_response", "Send a request through Backbone's OpenAI-compatible OpenResponses endpoint (POST /v1/responses). Model format: 'provider/model' for a direct gateway call via `model`, or use `agent` to invoke a stored agent by id or name (optionally 'ref@label', e.g. 'support-bot@latest'). With `agent`, `agentModel` runs this one request on another model from the agent version's `models` list. The reply ends with the model that answered. Always non-streaming.", {
96
+ server.tool("2kw_create_response", "Send a request through Backbone's OpenAI-compatible OpenResponses endpoint (POST /v1/responses). Model format: 'provider/model' for a direct gateway call via `model`, or use `agent` to invoke a stored agent by id or name (optionally 'ref@label', e.g. 'support-bot@latest'). With `agent`, `agentModel` runs this one request on another model from the agent version's `models` list. With `agent`, the reply is the run envelope as JSON (status, text, tool calls, pending approvals, response id, next step) followed by the model that answered; answer an approval pause with 2kw_decide_agent_approvals. A run that needs the user's own sign-in to a connector pauses with status requires_tool_output and `pendingConnections` (label, host, reason): no tool here can answer that; tell the user to connect it in chat.2kw.ai → Connectors, as the envelope's `next` says. Always non-streaming. " + MODE_RULE, {
96
97
  input: z.string().min(1).describe("The input text (sent as a single user message)"),
97
98
  agent: z
98
99
  .string()
@@ -108,7 +109,11 @@ export function register(server, client) {
108
109
  .optional()
109
110
  .describe("Model identifier in 'provider/model' format. Mutually exclusive with `agent`."),
110
111
  conversation: z.string().optional().describe("Conversation ID this response belongs to"),
111
- }, async ({ input, agent, agentModel, model, conversation }) => {
112
+ mode: z
113
+ .enum(["plan", "ask", "auto"])
114
+ .optional()
115
+ .describe("Only with `agent`: the conversation mode for this and later turns (plan, ask or auto). Only on the user's explicit request."),
116
+ }, async ({ input, agent, agentModel, model, conversation, mode }) => {
112
117
  try {
113
118
  if (agent && model) {
114
119
  throw new Error("Use either `agent` or `model`, not both.");
@@ -122,13 +127,16 @@ export function register(server, client) {
122
127
  if (agentModel !== undefined && agent?.includes("#")) {
123
128
  throw new Error("Give the model switch once: either 'ref#model' in `agent` or `agentModel`.");
124
129
  }
130
+ if (mode !== undefined && !agent) {
131
+ throw new Error("`mode` applies to agent runs; it needs `agent`.");
132
+ }
125
133
  const resolvedModel = agentModelReference(agent, agentModel) ?? model;
126
134
  const body = {
127
135
  model: resolvedModel,
128
- // The wire `input` field also accepts a bare string (shorthand for
129
- // a single user message; see ResponseItemInputDeserializer), which
130
- // the generated type does not model.
131
- input,
136
+ // Without `mode` the wire `input` is the bare string (shorthand for one user message; see
137
+ // ResponseItemInputDeserializer), which the generated type does not model. With it, the
138
+ // backbone:mode item follows the message (#656).
139
+ input: mode ? [{ type: "message", role: "user", content: input }, modeItem(mode)] : input,
132
140
  stream: false,
133
141
  ...(conversation !== undefined && { conversation }),
134
142
  };
@@ -137,6 +145,17 @@ export function register(server, client) {
137
145
  body: body,
138
146
  });
139
147
  const result = data;
148
+ if (agent) {
149
+ // The reference the run used, so the envelope's `next` keeps its label and model (D11).
150
+ const agentRef = agentModel !== undefined ? `${agent}#${agentModel}` : agent;
151
+ const envelope = buildRunEnvelope(result, agentRef, chatUrlFor(client._config?.baseUrl));
152
+ const content = [{ type: "text", text: JSON.stringify(envelope, null, 2) }];
153
+ // The echoed model is 'agent/<name>@<version>', plus '#<model>' when the request switched it (#591).
154
+ if (typeof result.model === "string") {
155
+ content.push({ type: "text", text: `[model: ${result.model}]` });
156
+ }
157
+ return { content };
158
+ }
140
159
  const parts = [];
141
160
  const output = result.output;
142
161
  for (const item of output ?? []) {
@@ -162,11 +181,6 @@ export function register(server, client) {
162
181
  if (parts.length === 0) {
163
182
  parts.push({ type: "text", text: JSON.stringify(result, null, 2) });
164
183
  }
165
- // The response echoes the model that answered: for an agent run
166
- // 'agent/<name>@<version>', plus '#<model>' when the request switched it (#591).
167
- if (agent && typeof result.model === "string") {
168
- parts.push({ type: "text", text: `[model: ${result.model}]` });
169
- }
170
184
  return { content: parts };
171
185
  }
172
186
  catch (error) {
@@ -134,6 +134,32 @@ export function register(server, client) {
134
134
  };
135
135
  }
136
136
  });
137
+ // ── cancel_conversation_turn ─────────────────────────────────────────────
138
+ server.tool("2kw_cancel_conversation_turn", "Ask a running turn of a conversation to stop. turnId is the value the client sent as the Backbone-Turn-Id header on the responses call. The stop is accepted, not confirmed: the turn's own response reports status cancelled, or completed when the stop arrived too late.", {
139
+ conversationId: z.string().describe("The conversation ID"),
140
+ turnId: z
141
+ .string()
142
+ .regex(/^[A-Za-z0-9_-]{1,64}$/)
143
+ .describe("The turn id from the Backbone-Turn-Id header: 1-64 characters of A-Z, a-z, 0-9, '_' or '-'"),
144
+ }, async ({ conversationId, turnId }) => {
145
+ try {
146
+ await client.POST("/v1/conversations/{conversationId}/cancel", {
147
+ params: { path: { conversationId } },
148
+ body: { turn_id: turnId },
149
+ });
150
+ return {
151
+ content: [
152
+ { type: "text", text: `Stop requested for turn ${turnId} of conversation ${conversationId}.` },
153
+ ],
154
+ };
155
+ }
156
+ catch (error) {
157
+ return {
158
+ content: [{ type: "text", text: formatErrorForMcp(error) }],
159
+ isError: true,
160
+ };
161
+ }
162
+ });
137
163
  // ── list_conversation_items ──────────────────────────────────────────────
138
164
  server.tool("2kw_list_conversation_items", "Fetch a conversation's items in replay order, oldest first.", { conversationId: z.string().describe("The conversation ID") }, async ({ conversationId }) => {
139
165
  try {
@@ -157,7 +157,7 @@ export function register(server, client) {
157
157
  server.tool("2kw_add_variant", "Add a variant to an experiment with a task type and configuration.", {
158
158
  experimentId: z.string().describe("The experiment ID"),
159
159
  name: z.string().min(1).describe("Variant name"),
160
- taskType: z.string().min(1).describe("Task type (e.g., EXTRACTION, CLASSIFICATION)"),
160
+ taskType: z.string().min(1).describe("Task type: extraction or agent"),
161
161
  configuration: z.unknown().describe("Variant configuration (JSON object)"),
162
162
  description: z.string().optional().describe("Variant description"),
163
163
  sortOrder: z.number().optional().describe("Sort order for display"),
@@ -214,7 +214,7 @@ export function register(server, client) {
214
214
  experimentId: z.string().describe("The experiment ID"),
215
215
  variantId: z.string().describe("The variant ID"),
216
216
  name: z.string().optional().describe("New variant name"),
217
- taskType: z.string().optional().describe("New task type"),
217
+ taskType: z.string().optional().describe("Task type; must equal the variant's current one (it cannot change)"),
218
218
  configuration: z.unknown().optional().describe("New variant configuration (JSON object)"),
219
219
  description: z.string().optional().describe("New variant description"),
220
220
  sortOrder: z.number().optional().describe("New sort order for display"),
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { basename } from "node:path";
4
- import { BackboneApiError, formatErrorForMcp } from "../errors.js";
4
+ import { formatErrorForMcp } from "../errors.js";
5
5
  import { getMimeType } from "../mime.js";
6
6
  const ChunkingStrategy = z.enum(["AUTO", "FIXED", "HIERARCHICAL", "CUSTOM"]);
7
7
  const DocumentStatus = z.enum(["PENDING", "PARSING", "CHUNKING", "EMBEDDING", "READY", "ERROR"]);
@@ -203,29 +203,13 @@ export function register(server, client) {
203
203
  const filename = basename(filePath);
204
204
  formData.append("files", new Blob([buffer], { type: getMimeType(filename) }), filename);
205
205
  }
206
- const { baseUrl, apiKey } = client._config;
207
- const res = await fetch(`${baseUrl}/v1/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/documents`, {
208
- method: "POST",
209
- headers: { Authorization: `Bearer ${apiKey}` },
210
- body: formData,
206
+ const { data } = await client.POST("/v1/knowledge-bases/{knowledgeBaseId}/documents", {
207
+ params: { path: { knowledgeBaseId } },
208
+ // Provide placeholder body for type checking; actual payload comes from bodySerializer
209
+ body: { files: [] },
210
+ bodySerializer: () => formData,
211
211
  });
212
- if (!res.ok) {
213
- let body;
214
- try {
215
- body = await res.json();
216
- }
217
- catch {
218
- body = {
219
- error: res.statusText,
220
- message: `HTTP ${res.status}: ${res.statusText}`,
221
- status: res.status,
222
- timestamp: new Date().toISOString(),
223
- };
224
- }
225
- throw new BackboneApiError(body);
226
- }
227
- const data = (await res.json());
228
- const lines = data.map((r) => r.error
212
+ const lines = (data ?? []).map((r) => r.error
229
213
  ? `- ${r.filename}: rejected — ${r.error}`
230
214
  : `- ${r.filename}: accepted, documentId=${r.upload?.document?.id}, versionId=${r.upload?.acceptedVersion?.id}, status=${r.upload?.acceptedVersion?.status}`);
231
215
  return {
@@ -0,0 +1,9 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { ApiClient } from "../client.js";
3
+ /**
4
+ * Agent memory of organization members (agent memory spec §7.1, §7.4). The "my" tools act on
5
+ * the API key owner's own memory; the member tools are for ADMIN and OWNER keys and never
6
+ * show paths or content.
7
+ */
8
+ export declare function register(server: McpServer, client: ApiClient): void;
9
+ //# sourceMappingURL=memory.d.ts.map
@@ -0,0 +1,43 @@
1
+ import { z } from "zod";
2
+ import { formatErrorForMcp } from "../errors.js";
3
+ async function reply(action) {
4
+ try {
5
+ const data = await action();
6
+ return {
7
+ content: [{ type: "text", text: data === undefined ? "Done." : JSON.stringify(data, null, 2) }],
8
+ };
9
+ }
10
+ catch (error) {
11
+ return { content: [{ type: "text", text: formatErrorForMcp(error) }], isError: true };
12
+ }
13
+ }
14
+ const path = z.string().min(1).describe("Memory path, /memories or below, e.g. /memories/preferences.md");
15
+ /**
16
+ * Agent memory of organization members (agent memory spec §7.1, §7.4). The "my" tools act on
17
+ * the API key owner's own memory; the member tools are for ADMIN and OWNER keys and never
18
+ * show paths or content.
19
+ */
20
+ export function register(server, client) {
21
+ server.tool("2kw_list_my_memory_files", "List the files in your agent memory (the API key owner's), ordered by path, without content.", {}, async () => reply(async () => (await client.GET("/v1/memories/me/files")).data));
22
+ server.tool("2kw_read_my_memory_file", "Read one of your agent memory files: the raw content, its version (for ifMatchVersion) and which agent wrote it last.", { path }, async ({ path }) => reply(async () => (await client.GET("/v1/memories/me/file", { params: { query: { path } } })).data));
23
+ server.tool("2kw_write_my_memory_file", "Create or replace one of your agent memory files with the complete content. Pass ifMatchVersion (the version from 2kw_read_my_memory_file) to refuse the write if the file changed since. Credentials are refused; files are limited to 32 KB.", {
24
+ path,
25
+ content: z.string().describe("The complete UTF-8 text content of the file"),
26
+ ifMatchVersion: z.number().int().min(0).optional().describe("Only replace the file if it still has this version"),
27
+ }, async ({ path, content, ifMatchVersion }) => reply(async () => (await client.PUT("/v1/memories/me/file", {
28
+ params: { query: { path } },
29
+ body: { content },
30
+ headers: ifMatchVersion === undefined ? undefined : { "If-Match": `"${ifMatchVersion}"` },
31
+ })).data));
32
+ server.tool("2kw_delete_my_memory_file", "Delete one of your agent memory files, or a directory with everything below it. Irreversible.", { path }, async ({ path }) => reply(async () => {
33
+ await client.DELETE("/v1/memories/me/file", { params: { query: { path } } });
34
+ }));
35
+ server.tool("2kw_forget_my_memory", "Delete your whole agent memory. Irreversible: confirm with the user before calling.", {}, async () => reply(async () => {
36
+ await client.DELETE("/v1/memories/me");
37
+ }));
38
+ server.tool("2kw_list_member_memory_usage", "Agent memory totals per organization member: file count, total bytes and last write. Never paths or content. Needs an ADMIN or OWNER key.", {}, async () => reply(async () => (await client.GET("/v1/memories/users")).data));
39
+ server.tool("2kw_erase_member_memory", "Erase one member's whole agent memory in this organization. Irreversible: confirm with the user before calling. Needs an ADMIN or OWNER key.", { userId: z.string().min(1).describe("The member's user id, from 2kw_list_member_memory_usage") }, async ({ userId }) => reply(async () => {
40
+ await client.DELETE("/v1/memories/users/{userId}", { params: { path: { userId } } });
41
+ }));
42
+ }
43
+ //# sourceMappingURL=memory.js.map
@@ -2,7 +2,7 @@ import { z } from "zod";
2
2
  import { formatErrorForMcp } from "../errors.js";
3
3
  export function register(server, client) {
4
4
  // ── settings ────────────────────────────────────────────────────────────
5
- server.tool("2kw_get_tracing_settings", "Return the org's tracing settings (includePrompts / includeCompletions). These control whether raw prompt and completion content are persisted on ingested spans. Defaults are false for both.", {}, async () => {
5
+ server.tool("2kw_get_tracing_settings", "Return the org's tracing settings: includePrompts / includeCompletions (whether raw prompt and completion content are persisted on ingested spans; false by default) and trace retention: retentionDays (the org's choice, null = follow the plan), planRetentionDays and effectiveRetentionDays.", {}, async () => {
6
6
  try {
7
7
  const { data } = await client.GET("/v1/tracing/settings");
8
8
  return {
@@ -16,7 +16,7 @@ export function register(server, client) {
16
16
  };
17
17
  }
18
18
  });
19
- server.tool("2kw_update_tracing_settings", "Update the org's tracing settings. Admin-only. Omit a field to leave it at its current value — the tool reads the current setting first and merges.", {
19
+ server.tool("2kw_update_tracing_settings", "Update the org's tracing settings. Admin-only. Omit a field to leave it at its current value — the tool reads the current setting first and merges. Shortening retentionDays deletes older traces at the next daily sweep.", {
20
20
  includePrompts: z
21
21
  .boolean()
22
22
  .optional()
@@ -25,12 +25,20 @@ export function register(server, client) {
25
25
  .boolean()
26
26
  .optional()
27
27
  .describe("Retain completion content on ingested spans (default false)"),
28
+ retentionDays: z
29
+ .number()
30
+ .int()
31
+ .min(1)
32
+ .nullable()
33
+ .optional()
34
+ .describe("Trace retention in days, at most the plan's limit (planRetentionDays). null follows the plan. Shortening deletes older traces at the next daily sweep."),
28
35
  }, async (params) => {
29
36
  try {
30
37
  const { data: current } = await client.GET("/v1/tracing/settings");
31
38
  const merged = {
32
39
  includePrompts: params.includePrompts ?? current?.includePrompts ?? false,
33
40
  includeCompletions: params.includeCompletions ?? current?.includeCompletions ?? false,
41
+ retentionDays: params.retentionDays !== undefined ? params.retentionDays : (current?.retentionDays ?? null),
34
42
  };
35
43
  const { data } = await client.PUT("/v1/tracing/settings", {
36
44
  body: merged,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@2kw/ai-mcp-server",
3
- "version": "6.3.0-dev.8",
3
+ "version": "6.3.0-dev.84",
4
4
  "description": "MCP server for 2kw.ai — EU-hosted AI platform: OpenAI-compatible LLM gateway, schema-driven document extraction, transcription, agents with a knowledge base, and cost observability. 158 tools for Claude Code, Cursor, and Windsurf.",
5
5
  "mcpName": "ai.2kw/mcp-server",
6
6
  "keywords": [