@2kw/ai-mcp-server 7.0.0-dev.4 → 7.0.0-dev.8

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.
@@ -5,7 +5,7 @@
5
5
  * The connect pause is not copied by hand: `connect-pause.ts` is a guarded byte copy of the CLI's (#1086).
6
6
  */
7
7
  import type { components } from "../generated/openapi.js";
8
- import { DEFAULT_CHAT_URL } from "./connect-pause.js";
8
+ import { DEFAULT_CHAT_URL, type InputMode } from "./connect-pause.js";
9
9
  type AnyRecord = Record<string, any>;
10
10
  export type RunStatus = "completed" | "requires_approval" | "requires_tool_output" | "incomplete";
11
11
  /** A built-in SkillsApply approval's preview (#781, spec §6); the generated type, fields all optional. */
@@ -52,6 +52,19 @@ export { DEFAULT_CHAT_URL };
52
52
  * mapping itself is the shared `connect-pause.ts`'s {@link chatOriginFor} (#1086).
53
53
  */
54
54
  export declare function chatUrlFor(baseUrl: string | undefined, env?: NodeJS.ProcessEnv): string;
55
+ /**
56
+ * A connector's question the run waits on (#1320, connectors spec §12): an open
57
+ * `backbone:input_request`. Only the user who started the run answers it, and only in chat, at
58
+ * `answerUrl`; the envelope never carries the question, its form or its link (D7).
59
+ */
60
+ export interface PendingInput {
61
+ inputRequestId: string;
62
+ serverLabel: string;
63
+ tool: string;
64
+ /** `form`, `url`, `mixed` (both) or `unknown`: what the user does in chat. */
65
+ mode: InputMode;
66
+ answerUrl: string;
67
+ }
55
68
  /** The end user's conversation mode (epic &59); the server matches these three values exactly. */
56
69
  export type ConversationMode = "plan" | "ask" | "auto";
57
70
  export declare const CONVERSATION_MODES: readonly ConversationMode[];
@@ -83,6 +96,7 @@ export interface RunEnvelope {
83
96
  pendingApprovals: PendingApproval[];
84
97
  pendingToolCalls: PendingToolCall[];
85
98
  pendingConnections: PendingConnection[];
99
+ pendingInputs: PendingInput[];
86
100
  incompleteReason: string | null;
87
101
  usage: {
88
102
  inputTokens: number;
@@ -1,4 +1,4 @@
1
- import { chatOriginFor, connectCallIds, connectionsPhrase, DEFAULT_CHAT_URL, pendingConnectionsOf, } from "./connect-pause.js";
1
+ import { answerUrlFor, chatOriginFor, connectCallIds, connectionsPhrase, DEFAULT_CHAT_URL, inputLabelsPhrase, pendingConnectionsOf, pendingInputsOf, } from "./connect-pause.js";
2
2
  export { DEFAULT_CHAT_URL };
3
3
  /**
4
4
  * The chat web host that belongs to the server's API base URL (`AI_2KW_BASE_URL`), where a member
@@ -104,6 +104,7 @@ const DECODED_ITEM_TYPES = new Set([
104
104
  "function_call_output",
105
105
  "backbone:approval_request",
106
106
  "backbone:connector_auth_request",
107
+ "backbone:input_request",
107
108
  "backbone:tool_image",
108
109
  "mcp_approval_request",
109
110
  "mcp_call",
@@ -170,7 +171,22 @@ export function buildRunEnvelope(result, agentRef, chatUrl = DEFAULT_CHAT_URL) {
170
171
  reason,
171
172
  ...(destinations ? { destinations } : {}),
172
173
  }));
173
- const withheldCallIds = new Set([...pendingApprovals.map((a) => a.callId), ...connectCallIds(output, connectRequests)]);
174
+ // A connector's question (#1320): answered in chat only, so the envelope names where, never what (D7).
175
+ const inputRequests = pendingInputsOf(output);
176
+ const answerUrl = answerUrlFor(chatUrl, result?.conversation?.id ?? null);
177
+ const pendingInputs = inputRequests.map(({ id, serverLabel, tool, mode }) => ({
178
+ inputRequestId: id,
179
+ serverLabel,
180
+ tool,
181
+ mode,
182
+ answerUrl,
183
+ }));
184
+ const withheldCallIds = new Set([
185
+ ...pendingApprovals.map((a) => a.callId),
186
+ ...connectCallIds(output, connectRequests),
187
+ // An awaiting call has no function_call item (spec §5); withheld all the same, never the caller's to answer.
188
+ ...inputRequests.map((i) => i.callId),
189
+ ]);
174
190
  const toolCalls = [];
175
191
  const pendingToolCalls = [];
176
192
  for (const item of output) {
@@ -194,9 +210,10 @@ export function buildRunEnvelope(result, agentRef, chatUrl = DEFAULT_CHAT_URL) {
194
210
  status = "incomplete";
195
211
  break;
196
212
  case "requires_action":
197
- // A connect pause is requires_tool_output too (#807 R12): no tool here can answer it.
213
+ // A connect pause is requires_tool_output too (#807 R12): no tool here can answer it, nor an
214
+ // input pause, which outranks every other (#1320, P91).
198
215
  status =
199
- pendingToolCalls.length > 0 || pendingConnections.length > 0 || pendingApprovals.length === 0
216
+ pendingInputs.length > 0 || pendingToolCalls.length > 0 || pendingConnections.length > 0 || pendingApprovals.length === 0
200
217
  ? "requires_tool_output"
201
218
  : "requires_approval";
202
219
  break;
@@ -206,7 +223,17 @@ export function buildRunEnvelope(result, agentRef, chatUrl = DEFAULT_CHAT_URL) {
206
223
  const responseId = result?.id ?? null;
207
224
  const ref = agentRef ?? agent;
208
225
  let next = null;
209
- if (status === "requires_approval" && ref && responseId) {
226
+ if (pendingInputs.length > 0) {
227
+ // Only the requester's continuation resolves the response (spec D9), and only chat sends one: no
228
+ // tool here continues it, whatever else waits (#1320, P91). The question is the server's text for
229
+ // the user, never for the assistant (D7), so the assistant is told not to collect the answer.
230
+ const labels = inputLabelsPhrase(pendingInputs);
231
+ const needs = new Set(pendingInputs.map((i) => i.serverLabel)).size > 1 ? "need" : "needs";
232
+ next =
233
+ `Tell the user that ${labels} ${needs} their input and that they answer in chat: ${answerUrl}. ` +
234
+ "No tool here can answer it, and the run continues in chat. Do not ask the user for the answer here.";
235
+ }
236
+ else if (status === "requires_approval" && ref && responseId) {
210
237
  // Never an "approve all" hint: the assistant decides only what the user decided (D12).
211
238
  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)}.`;
212
239
  }
@@ -275,6 +302,7 @@ export function buildRunEnvelope(result, agentRef, chatUrl = DEFAULT_CHAT_URL) {
275
302
  pendingApprovals,
276
303
  pendingToolCalls,
277
304
  pendingConnections,
305
+ pendingInputs,
278
306
  incompleteReason: result?.incomplete_details?.reason ?? null,
279
307
  usage: result?.usage
280
308
  ? { inputTokens: result.usage.input_tokens ?? 0, outputTokens: result.usage.output_tokens ?? 0 }
@@ -7,7 +7,8 @@
7
7
  * `n8n/nodes/TwoKw/operations/connect-pause.ts` are byte copies of it under a header: edit it
8
8
  * here, then run `npm run sync:connect-pause` in mcp/ and `npm run sync-connect-pause` in n8n/.
9
9
  * Its golden fixture, `cli/tests/fixtures/connect-pause-cases.json`, is copied the same way into
10
- * mcp/, n8n/ and surface/, and every client's decoder test runs every case of it.
10
+ * mcp/, n8n/ and surface/, and every client's decoder test runs every case of it. The input pause
11
+ * (#1320, connectors spec §12) lives here too, with its own fixture `input-pause-cases.json`.
11
12
  *
12
13
  * Plain TypeScript with no imports, no `process`, no Node types and no timers, so n8n's source
13
14
  * scanner and its empty `dependencies` accept the copy. The CLI and the MCP server keep their
@@ -68,4 +69,37 @@ export declare function connectCallIds(output: unknown, connections: readonly Pi
68
69
  export declare function connectionPhrase(c: ConnectTarget): string;
69
70
  /** "connect a (h), allow the agent to use b (h) and reconnect c (h)"; empty for none. */
70
71
  export declare function connectionsPhrase(connections: readonly ConnectTarget[]): string;
72
+ /**
73
+ * What an open input request asks for: a form, a page to open, both (`mixed`), or neither of the
74
+ * modes this client knows (`unknown`). It names no URL: the question stays in chat (D7).
75
+ */
76
+ export type InputMode = "form" | "url" | "mixed" | "unknown";
77
+ /**
78
+ * One open `backbone:input_request` (#1320, connectors spec §12): a connector's question that only
79
+ * the requester can answer, and only in chat. `id` is the item's `inreq_…` id. Its message, schema
80
+ * and URL are never read: no assistant may see them (D7).
81
+ */
82
+ export interface PendingInput {
83
+ id: string;
84
+ callId: string;
85
+ serverLabel: string;
86
+ /** The tool's own name, without the `mcp__<label>__` prefix. */
87
+ tool: string;
88
+ mode: InputMode;
89
+ }
90
+ /**
91
+ * The questions a response's `output` still waits on, in order, by the chat's own rule
92
+ * (`surface/src/core/chat.ts`, so a link never points at a question the chat would not show): an
93
+ * `in_progress` request whose id no other-status projection and no `backbone:input_response`
94
+ * resolved, and only the newest open round of its call. An item without a non-empty `id`,
95
+ * `call_id`, `server_label` or `name`, or without one request entry that has a `key`, is skipped.
96
+ */
97
+ export declare function pendingInputsOf(output: unknown): PendingInput[];
98
+ /**
99
+ * Where the user answers: the chat's slug-less conversation link (`<chat>/c/<id>`, the path
100
+ * `surface/src/hosts/web/routes.ts` `answerPath` builds), or the chat itself without a conversation.
101
+ */
102
+ export declare function answerUrlFor(chatOrigin: string, conversationId: string | null | undefined): string;
103
+ /** "erp", "erp and crm", "erp, crm and drive": each connector that asks, once; empty for none. */
104
+ export declare function inputLabelsPhrase(inputs: readonly Pick<PendingInput, "serverLabel">[]): string;
71
105
  //# sourceMappingURL=connect-pause.d.ts.map
@@ -9,7 +9,8 @@
9
9
  * `n8n/nodes/TwoKw/operations/connect-pause.ts` are byte copies of it under a header: edit it
10
10
  * here, then run `npm run sync:connect-pause` in mcp/ and `npm run sync-connect-pause` in n8n/.
11
11
  * Its golden fixture, `cli/tests/fixtures/connect-pause-cases.json`, is copied the same way into
12
- * mcp/, n8n/ and surface/, and every client's decoder test runs every case of it.
12
+ * mcp/, n8n/ and surface/, and every client's decoder test runs every case of it. The input pause
13
+ * (#1320, connectors spec §12) lives here too, with its own fixture `input-pause-cases.json`.
13
14
  *
14
15
  * Plain TypeScript with no imports, no `process`, no Node types and no timers, so n8n's source
15
16
  * scanner and its empty `dependencies` accept the copy. The CLI and the MCP server keep their
@@ -146,4 +147,77 @@ export function connectionsPhrase(connections) {
146
147
  return phrases[0] ?? "";
147
148
  return `${phrases.slice(0, -1).join(", ")} and ${phrases[phrases.length - 1]}`;
148
149
  }
150
+ const INPUT_REQUEST = "backbone:input_request";
151
+ const INPUT_RESPONSE = "backbone:input_response";
152
+ function roundOf(value) {
153
+ return typeof value === "number" && Number.isInteger(value) ? value : 1;
154
+ }
155
+ function inputModeOf(requests) {
156
+ const modes = new Set(requests.map((entry) => entry.mode).filter((mode) => mode === "form" || mode === "url"));
157
+ if (modes.size === 2)
158
+ return "mixed";
159
+ if (modes.has("form"))
160
+ return "form";
161
+ return modes.has("url") ? "url" : "unknown";
162
+ }
163
+ /**
164
+ * The questions a response's `output` still waits on, in order, by the chat's own rule
165
+ * (`surface/src/core/chat.ts`, so a link never points at a question the chat would not show): an
166
+ * `in_progress` request whose id no other-status projection and no `backbone:input_response`
167
+ * resolved, and only the newest open round of its call. An item without a non-empty `id`,
168
+ * `call_id`, `server_label` or `name`, or without one request entry that has a `key`, is skipped.
169
+ */
170
+ export function pendingInputsOf(output) {
171
+ const items = itemsOf(output);
172
+ const resolved = new Set();
173
+ const newestRound = new Map();
174
+ for (const item of items) {
175
+ if (item.type === INPUT_REQUEST) {
176
+ const id = text(item.id);
177
+ if (id && item.status !== "in_progress")
178
+ resolved.add(id);
179
+ const callId = text(item.call_id);
180
+ if (callId && item.status === "in_progress") {
181
+ newestRound.set(callId, Math.max(newestRound.get(callId) ?? 0, roundOf(item.round)));
182
+ }
183
+ }
184
+ else if (item.type === INPUT_RESPONSE) {
185
+ const id = text(item.input_request_id);
186
+ if (id)
187
+ resolved.add(id);
188
+ }
189
+ }
190
+ const pending = new Map();
191
+ for (const item of items) {
192
+ if (item.type !== INPUT_REQUEST || item.status !== "in_progress")
193
+ continue;
194
+ const id = text(item.id);
195
+ const callId = text(item.call_id);
196
+ const serverLabel = text(item.server_label);
197
+ const tool = text(item.name);
198
+ if (!id || !callId || !serverLabel || !tool || resolved.has(id) || pending.has(id))
199
+ continue;
200
+ if (roundOf(item.round) !== newestRound.get(callId))
201
+ continue;
202
+ const requests = itemsOf(item.requests).filter((entry) => text(entry.key) !== undefined);
203
+ if (requests.length === 0)
204
+ continue;
205
+ pending.set(id, { id, callId, serverLabel, tool, mode: inputModeOf(requests) });
206
+ }
207
+ return [...pending.values()];
208
+ }
209
+ /**
210
+ * Where the user answers: the chat's slug-less conversation link (`<chat>/c/<id>`, the path
211
+ * `surface/src/hosts/web/routes.ts` `answerPath` builds), or the chat itself without a conversation.
212
+ */
213
+ export function answerUrlFor(chatOrigin, conversationId) {
214
+ return conversationId ? `${chatOrigin}/c/${encodeURIComponent(conversationId)}` : `${chatOrigin}/`;
215
+ }
216
+ /** "erp", "erp and crm", "erp, crm and drive": each connector that asks, once; empty for none. */
217
+ export function inputLabelsPhrase(inputs) {
218
+ const labels = [...new Set(inputs.map((input) => input.serverLabel))];
219
+ if (labels.length < 2)
220
+ return labels[0] ?? "";
221
+ return `${labels.slice(0, -1).join(", ")} and ${labels[labels.length - 1]}`;
222
+ }
149
223
  //# sourceMappingURL=connect-pause.js.map
@@ -556,7 +556,8 @@ export function register(server, client) {
556
556
  + "Otherwise answer it with `failed: true` and output \"not available in this client\". Answer within one hour of the pause. "
557
557
  + "Pass `agent` exactly as the run was started (keep '@label' and '#model'). "
558
558
  + "Returns the continuation's run envelope, which can pause again: for approval, on another relayed tool call (answer it the "
559
- + "same way), or on a connector the user must connect in chat.2kw.ai → Connectors (`pendingConnections`; follow its `next`)."
559
+ + "same way), or on a connector the user must connect in chat.2kw.ai → Connectors (`pendingConnections`; follow its `next`), "
560
+ + "or on a connector's question the user answers in chat (`pendingInputs`; follow its `next`)."
560
561
  + " " + MODE_RULE, {
561
562
  agent: z.string().min(1).describe("Agent id or name as the run used it: 'ref[@label][#model]'"),
562
563
  responseId: z.string().min(1).describe("The paused response's id (envelope `responseId`)"),
@@ -93,7 +93,7 @@ export function register(server, client) {
93
93
  }
94
94
  });
95
95
  // ── create_response ─────────────────────────────────────────────────────
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 paused on a relayed (client-side) tool call comes back with status requires_tool_output and `pendingToolCalls`; answer it with 2kw_decide_agent_approvals `outputs`. 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
+ 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 paused on a relayed (client-side) tool call comes back with status requires_tool_output and `pendingToolCalls`; answer it with 2kw_decide_agent_approvals `outputs`. 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. A run where a connector asks the user a question pauses with status requires_tool_output and `pendingInputs` (label, tool, mode, answerUrl): the user answers it in chat at `answerUrl`, not here. Always non-streaming. " + MODE_RULE, {
97
97
  input: z.string().min(1).describe("The input text (sent as a single user message)"),
98
98
  agent: z
99
99
  .string()
@@ -1,4 +1,12 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import type { ApiClient } from "../client.js";
3
+ /**
4
+ * A conversation's items as an assistant may read them (#1320, connectors spec D7): a connector's
5
+ * question is the server's text for the user, never for an assistant, so a `backbone:input_request`
6
+ * keeps each request's `key` and `mode` only (no message, schema or link) and a
7
+ * `backbone:input_response` keeps each answer's `action` only (no content). Every other item and
8
+ * field passes through unchanged.
9
+ */
10
+ export declare function withoutInputContent(list: unknown): unknown;
3
11
  export declare function register(server: McpServer, client: ApiClient): void;
4
12
  //# sourceMappingURL=conversations.d.ts.map
@@ -1,5 +1,35 @@
1
1
  import { z } from "zod";
2
2
  import { formatErrorForMcp } from "../errors.js";
3
+ const isItem = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
4
+ /**
5
+ * A conversation's items as an assistant may read them (#1320, connectors spec D7): a connector's
6
+ * question is the server's text for the user, never for an assistant, so a `backbone:input_request`
7
+ * keeps each request's `key` and `mode` only (no message, schema or link) and a
8
+ * `backbone:input_response` keeps each answer's `action` only (no content). Every other item and
9
+ * field passes through unchanged.
10
+ */
11
+ export function withoutInputContent(list) {
12
+ if (!isItem(list) || !Array.isArray(list.data))
13
+ return list;
14
+ return {
15
+ ...list,
16
+ data: list.data.map((item) => {
17
+ if (!isItem(item))
18
+ return item;
19
+ if (item.type === "backbone:input_request" && Array.isArray(item.requests)) {
20
+ return {
21
+ ...item,
22
+ requests: item.requests.map((entry) => (isItem(entry) ? { key: entry.key, mode: entry.mode } : null)),
23
+ };
24
+ }
25
+ if (item.type === "backbone:input_response" && isItem(item.responses)) {
26
+ const responses = Object.fromEntries(Object.entries(item.responses).map(([key, answer]) => [key, isItem(answer) ? { action: answer.action } : null]));
27
+ return { ...item, responses };
28
+ }
29
+ return item;
30
+ }),
31
+ };
32
+ }
3
33
  export function register(server, client) {
4
34
  // ── create_conversation ─────────────────────────────────────────────────
5
35
  server.tool("2kw_create_conversation", "Create a conversation whose id can be passed as the 'conversation' parameter on the responses API, so an agent turn can be replayed and extended across calls.", {
@@ -161,13 +191,13 @@ export function register(server, client) {
161
191
  }
162
192
  });
163
193
  // ── list_conversation_items ──────────────────────────────────────────────
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 }) => {
194
+ server.tool("2kw_list_conversation_items", "Fetch a conversation's items in replay order, oldest first. A connector's question to the user and the user's answer are listed without their text: the user reads and answers them in chat.", { conversationId: z.string().describe("The conversation ID") }, async ({ conversationId }) => {
165
195
  try {
166
196
  const { data } = await client.GET("/v1/conversations/{conversationId}/items", {
167
197
  params: { path: { conversationId } },
168
198
  });
169
199
  return {
170
- content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
200
+ content: [{ type: "text", text: JSON.stringify(withoutInputContent(data), null, 2) }],
171
201
  };
172
202
  }
173
203
  catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@2kw/ai-mcp-server",
3
- "version": "7.0.0-dev.4",
3
+ "version": "7.0.0-dev.8",
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": [