@stigmer/mcp-server 3.5.3 → 3.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli/mcp-server-stigmer.js +6 -4
- package/config.d.ts +5 -1
- package/config.d.ts.map +1 -1
- package/config.js +2 -2
- package/config.js.map +1 -1
- package/domains/channels/calls.d.ts +15 -0
- package/domains/channels/calls.d.ts.map +1 -0
- package/domains/channels/calls.js +54 -0
- package/domains/channels/calls.js.map +1 -0
- package/domains/channels/errors.d.ts +21 -0
- package/domains/channels/errors.d.ts.map +1 -0
- package/domains/channels/errors.js +77 -0
- package/domains/channels/errors.js.map +1 -0
- package/domains/channels/tools.d.ts +5 -0
- package/domains/channels/tools.d.ts.map +1 -0
- package/domains/channels/tools.js +90 -0
- package/domains/channels/tools.js.map +1 -0
- package/domains/conversation/calls.d.ts +9 -0
- package/domains/conversation/calls.d.ts.map +1 -0
- package/domains/conversation/calls.js +30 -0
- package/domains/conversation/calls.js.map +1 -0
- package/domains/conversation/errors.d.ts +19 -0
- package/domains/conversation/errors.d.ts.map +1 -0
- package/domains/conversation/errors.js +73 -0
- package/domains/conversation/errors.js.map +1 -0
- package/domains/conversation/tools.d.ts +13 -0
- package/domains/conversation/tools.d.ts.map +1 -0
- package/domains/conversation/tools.js +69 -0
- package/domains/conversation/tools.js.map +1 -0
- package/gen/mcpserver.js +2 -2
- package/gen/mcpserver.js.map +1 -1
- package/gen/workflow.d.ts +156 -63
- package/gen/workflow.d.ts.map +1 -1
- package/gen/workflow.js +98 -46
- package/gen/workflow.js.map +1 -1
- package/index.d.ts +1 -1
- package/index.d.ts.map +1 -1
- package/index.js +1 -1
- package/index.js.map +1 -1
- package/package.json +3 -3
- package/server.d.ts +54 -9
- package/server.d.ts.map +1 -1
- package/server.js +89 -8
- package/server.js.map +1 -1
- package/src/config.ts +9 -3
- package/src/domains/channels/calls.ts +86 -0
- package/src/domains/channels/channels.integration.test.ts +272 -0
- package/src/domains/channels/errors.ts +94 -0
- package/src/domains/channels/tools.ts +116 -0
- package/src/domains/conversation/calls.ts +41 -0
- package/src/domains/conversation/conversation.integration.test.ts +233 -0
- package/src/domains/conversation/errors.ts +88 -0
- package/src/domains/conversation/tools.ts +87 -0
- package/src/gen/mcpserver.ts +2 -2
- package/src/gen/workflow.ts +99 -40
- package/src/http.integration.test.ts +56 -2
- package/src/index.ts +11 -1
- package/src/server.ts +95 -12
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// The channels-domain error mapper — the records-domain sibling
|
|
2
|
+
// (DD-004 S-7's recorded posture: siblings share the idiom, never an
|
|
3
|
+
// abstraction over it; each domain documents its own contract).
|
|
4
|
+
//
|
|
5
|
+
// Channel-messaging domain errors carry agent-relayable messages that
|
|
6
|
+
// are contract bytes (proactive-messaging DD-002 D4's error table):
|
|
7
|
+
// "this agent has no proactive-messaging channel it can use", "channel
|
|
8
|
+
// is required: this agent serves multiple proactive-enabled channels:
|
|
9
|
+
// …", the OSS "proactive channel messaging requires Stigmer Cloud".
|
|
10
|
+
// The shared ../rpcerr.ts would rewrite them into transport advice and
|
|
11
|
+
// discard google.rpc.ErrorInfo; here the domain codes pass the server's
|
|
12
|
+
// message through VERBATIM as isError JSON `{error, code, reason}` so
|
|
13
|
+
// the model can self-correct (name the channel on INVALID_ARGUMENT;
|
|
14
|
+
// stop and relay on PERMISSION_DENIED). Transport codes still delegate
|
|
15
|
+
// to the shared helper, whose advice is right for them.
|
|
16
|
+
//
|
|
17
|
+
// Typed send outcomes (accepted/queued/refused) never reach this file:
|
|
18
|
+
// they are successful responses by design (DD-002 D4 — policy refusals
|
|
19
|
+
// are answers, not errors).
|
|
20
|
+
|
|
21
|
+
import { Code, ConnectError } from "@connectrpc/connect";
|
|
22
|
+
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
|
23
|
+
import { ErrorInfoSchema } from "@stigmer/protos/google/rpc/error_details_pb";
|
|
24
|
+
import { rpcError } from "../rpcerr.js";
|
|
25
|
+
import { errorResult, textResult } from "../toolresult.js";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The gRPC codes whose messages are the channel-messaging domain
|
|
29
|
+
* contract: reach denials (PERMISSION_DENIED, one fail-closed text per
|
|
30
|
+
* failure class), contract misuse with corrective copy
|
|
31
|
+
* (INVALID_ARGUMENT — ambiguous channel, ambiguous template language),
|
|
32
|
+
* and actionable preconditions (FAILED_PRECONDITION — the OSS refusal,
|
|
33
|
+
* not-installed, registry misconfiguration with its ErrorInfo reason).
|
|
34
|
+
*/
|
|
35
|
+
const DOMAIN_CODES: ReadonlySet<Code> = new Set([
|
|
36
|
+
Code.PermissionDenied,
|
|
37
|
+
Code.InvalidArgument,
|
|
38
|
+
Code.FailedPrecondition,
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
/** The structured error payload channel tools return for domain errors. */
|
|
42
|
+
export interface ChannelToolError {
|
|
43
|
+
/** The server's relayable message, byte-for-byte. */
|
|
44
|
+
error: string;
|
|
45
|
+
/** gRPC status name in SCREAMING_SNAKE (e.g. PERMISSION_DENIED). */
|
|
46
|
+
code: string;
|
|
47
|
+
/** ErrorInfo reason (e.g. WHATSAPP_MANAGEMENT_SCOPE_MISSING), when present. */
|
|
48
|
+
reason?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Run a channel-tool body: a string payload becomes a text result; a
|
|
53
|
+
* domain error becomes an isError JSON result carrying the verbatim
|
|
54
|
+
* message + ErrorInfo reason; a transport error delegates to the shared
|
|
55
|
+
* classifier. The only try/catch in this domain.
|
|
56
|
+
*/
|
|
57
|
+
export async function channelResult(
|
|
58
|
+
toolContext: string,
|
|
59
|
+
produce: () => Promise<string>,
|
|
60
|
+
): Promise<CallToolResult> {
|
|
61
|
+
try {
|
|
62
|
+
return textResult(await produce());
|
|
63
|
+
} catch (err) {
|
|
64
|
+
const ce = ConnectError.from(err);
|
|
65
|
+
if (DOMAIN_CODES.has(ce.code)) {
|
|
66
|
+
return {
|
|
67
|
+
content: [{ type: "text", text: JSON.stringify(domainError(ce)) }],
|
|
68
|
+
isError: true,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
return errorResult(rpcError(ce, toolContext));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Project a domain ConnectError into the structured tool payload. */
|
|
76
|
+
export function domainError(ce: ConnectError): ChannelToolError {
|
|
77
|
+
const payload: ChannelToolError = {
|
|
78
|
+
error: ce.rawMessage,
|
|
79
|
+
code: grpcStatusName(ce.code),
|
|
80
|
+
};
|
|
81
|
+
// The messaging RPCs attach google.rpc.ErrorInfo to operator-actionable
|
|
82
|
+
// preconditions (DD-005 D8); absence means the message alone carries
|
|
83
|
+
// the contract.
|
|
84
|
+
const details = ce.findDetails(ErrorInfoSchema);
|
|
85
|
+
if (details.length > 0 && details[0].reason !== "") {
|
|
86
|
+
payload.reason = details[0].reason;
|
|
87
|
+
}
|
|
88
|
+
return payload;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Connect's PascalCase code name → the gRPC SCREAMING_SNAKE status name. */
|
|
92
|
+
function grpcStatusName(code: Code): string {
|
|
93
|
+
return Code[code].replace(/([a-z0-9])([A-Z])/g, "$1_$2").toUpperCase();
|
|
94
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// The send_channel_message tool — the ONE tool of the channels roster
|
|
2
|
+
// (proactive-messaging DD-002 D7, arguments amended by DD-006 D5 to
|
|
3
|
+
// mirror the ChannelOutboundPayload oneof: `text` | `template`, exactly
|
|
4
|
+
// one).
|
|
5
|
+
//
|
|
6
|
+
// Agent audience only, by construction: this roster is what the
|
|
7
|
+
// runner-synthesized channel attachment connects to, and a session-bound
|
|
8
|
+
// caller's org derives from its token (an explicit org is rejected —
|
|
9
|
+
// the records T05 R3 rule), so no `org` argument exists to invite
|
|
10
|
+
// rejected calls. A direct-audience variant on the full roster is
|
|
11
|
+
// deliberately NOT registered: operators send through the console, CLI,
|
|
12
|
+
// and SDK, which carry the org+channel addressing the direct reach path
|
|
13
|
+
// requires.
|
|
14
|
+
//
|
|
15
|
+
// The typed outcome is the tool's answer, verbatim proto JSON:
|
|
16
|
+
// `accepted` (delivered to the provider), `queued` (transient failure —
|
|
17
|
+
// the platform retries in the background; do NOT resend), `refused`
|
|
18
|
+
// (terminal; `detail` says why — adapt, never retry the same send).
|
|
19
|
+
|
|
20
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
21
|
+
import { z } from "zod";
|
|
22
|
+
|
|
23
|
+
import { resolveToken, type BackendTarget } from "../client.js";
|
|
24
|
+
import { sendChannelMessage, type TemplateArg } from "./calls.js";
|
|
25
|
+
import { channelResult } from "./errors.js";
|
|
26
|
+
import { errorResult } from "../toolresult.js";
|
|
27
|
+
|
|
28
|
+
const templateShape = z.object({
|
|
29
|
+
name: z.string().describe("Approved template name on the channel's provider registry."),
|
|
30
|
+
language: z
|
|
31
|
+
.string()
|
|
32
|
+
.optional()
|
|
33
|
+
.describe(
|
|
34
|
+
"Template language code (e.g. en, en_US). Omit when the template exists in exactly one language.",
|
|
35
|
+
),
|
|
36
|
+
parameters: z
|
|
37
|
+
.record(z.string())
|
|
38
|
+
.optional()
|
|
39
|
+
.describe(
|
|
40
|
+
'Placeholder values. Positional templates key by position ("1", "2", …); ' +
|
|
41
|
+
'named templates key by parameter name ("member_name").',
|
|
42
|
+
),
|
|
43
|
+
header_image_link: z
|
|
44
|
+
.string()
|
|
45
|
+
.optional()
|
|
46
|
+
.describe(
|
|
47
|
+
"Public HTTPS URL for the image header. Required exactly when the template " +
|
|
48
|
+
"declares an image header.",
|
|
49
|
+
),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/** Register the channel messaging tool; returns the tool names. */
|
|
53
|
+
export function registerChannelTools(server: McpServer, target: BackendTarget): string[] {
|
|
54
|
+
server.registerTool(
|
|
55
|
+
"send_channel_message",
|
|
56
|
+
{
|
|
57
|
+
description:
|
|
58
|
+
"Send a business-initiated message to a recipient on this agent's messaging channel " +
|
|
59
|
+
"(e.g. WhatsApp). Exactly one of text | template. Outside a 24-hour customer-service " +
|
|
60
|
+
"window the provider only accepts a pre-approved template, so prefer a template from " +
|
|
61
|
+
"<available_channel_templates>. The result carries a typed outcome: accepted (sent), " +
|
|
62
|
+
"queued (the platform retries in the background — do not resend), or refused " +
|
|
63
|
+
"(terminal; detail says why — adapt, never retry the same send).",
|
|
64
|
+
inputSchema: {
|
|
65
|
+
recipient: z
|
|
66
|
+
.string()
|
|
67
|
+
.describe(
|
|
68
|
+
"Recipient's key on the channel's provider, passed to the provider verbatim. " +
|
|
69
|
+
'WhatsApp: the wa_id — digits only INCLUDING the country code, no "+" or ' +
|
|
70
|
+
'separators (e.g. "919912850490"). Never reformat a wa_id another tool returned.',
|
|
71
|
+
),
|
|
72
|
+
text: z
|
|
73
|
+
.string()
|
|
74
|
+
.optional()
|
|
75
|
+
.describe(
|
|
76
|
+
"Plain text body. Only deliverable inside an open 24-hour customer-service window.",
|
|
77
|
+
),
|
|
78
|
+
template: templateShape
|
|
79
|
+
.optional()
|
|
80
|
+
.describe("Pre-approved template send — required outside a 24-hour window."),
|
|
81
|
+
channel: z
|
|
82
|
+
.string()
|
|
83
|
+
.optional()
|
|
84
|
+
.describe(
|
|
85
|
+
"Channel slug. Only needed when this agent serves more than one " +
|
|
86
|
+
"proactive-messaging channel (a refusal will list them).",
|
|
87
|
+
),
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
(args, extra) => {
|
|
91
|
+
// Exactly-one, checked here with corrective copy the model can act
|
|
92
|
+
// on immediately; the server's required-oneof validation is the
|
|
93
|
+
// backstop (DD-006 D5).
|
|
94
|
+
const hasText = args.text !== undefined && args.text !== "";
|
|
95
|
+
const hasTemplate = args.template !== undefined;
|
|
96
|
+
if (hasText === hasTemplate) {
|
|
97
|
+
return Promise.resolve(errorResult(
|
|
98
|
+
hasText
|
|
99
|
+
? "supply exactly one of text | template, not both"
|
|
100
|
+
: "supply exactly one of text | template — text for an open 24-hour window, " +
|
|
101
|
+
"template otherwise",
|
|
102
|
+
));
|
|
103
|
+
}
|
|
104
|
+
return channelResult(`message to "${args.recipient}"`, () =>
|
|
105
|
+
sendChannelMessage(target.serverAddress, resolveToken(extra, target.apiKey), {
|
|
106
|
+
recipient: args.recipient,
|
|
107
|
+
text: args.text,
|
|
108
|
+
template: args.template as TemplateArg | undefined,
|
|
109
|
+
channel: args.channel,
|
|
110
|
+
}),
|
|
111
|
+
);
|
|
112
|
+
},
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
return ["send_channel_message"];
|
|
116
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Conversation RPC invocations for the escalate_to_human tool — a 1:1
|
|
2
|
+
// projection of ChannelConversationCommandController.escalate
|
|
3
|
+
// (channel-conversations DD-008: the tool layer adds no semantics; the
|
|
4
|
+
// reach, the idempotent writer, and the attention projection all live
|
|
5
|
+
// in the cloud handler, and the OSS edition refuses with the documented
|
|
6
|
+
// FAILED_PRECONDITION).
|
|
7
|
+
//
|
|
8
|
+
// The channels-domain calls.ts shape, with one deliberate divergence:
|
|
9
|
+
// the RPC's ChannelConversation response is DISCARDED instead of
|
|
10
|
+
// marshaled back as proto JSON. A15 rules the tool's answer is fixed
|
|
11
|
+
// behavioral copy (tools.ts owns it) — the row's control state,
|
|
12
|
+
// timestamps, and conversation key give the model nothing actionable,
|
|
13
|
+
// and reflecting them would only invite the model to narrate internal
|
|
14
|
+
// state to the customer.
|
|
15
|
+
|
|
16
|
+
import { create } from "@bufbuild/protobuf";
|
|
17
|
+
import {
|
|
18
|
+
ChannelConversationCommandController,
|
|
19
|
+
} from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/conversation_command_pb";
|
|
20
|
+
import {
|
|
21
|
+
EscalateConversationInputSchema,
|
|
22
|
+
} from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/conversation_io_pb";
|
|
23
|
+
import { withClient } from "../client.js";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Flag the calling session's conversation for human attention. The input
|
|
27
|
+
* carries only the reason: the conversation identity derives server-side
|
|
28
|
+
* from the session-scoped credential's channel labels (the DD-003
|
|
29
|
+
* identity doctrine), so there is nothing else a caller could
|
|
30
|
+
* legitimately send.
|
|
31
|
+
*/
|
|
32
|
+
export async function escalateConversation(
|
|
33
|
+
serverAddress: string,
|
|
34
|
+
token: string,
|
|
35
|
+
reason: string,
|
|
36
|
+
): Promise<void> {
|
|
37
|
+
const request = create(EscalateConversationInputSchema, { reason });
|
|
38
|
+
await withClient(ChannelConversationCommandController, serverAddress, token,
|
|
39
|
+
(client, opts) => client.escalate(request, opts),
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// In-process integration test for the conversation roster (the
|
|
2
|
+
// channels.integration.test.ts pattern: real Connect backend with a
|
|
3
|
+
// stubbed conversation service, real MCP client over an in-memory
|
|
4
|
+
// transport).
|
|
5
|
+
//
|
|
6
|
+
// Verifies the DD-008 D-b / A14 / A15 contract surface:
|
|
7
|
+
// - the conversation-only roster is exactly escalate_to_human with a
|
|
8
|
+
// single `reason` argument (agent audience — identity is
|
|
9
|
+
// server-derived, so nothing else exists to send);
|
|
10
|
+
// - the success answer is the A15 fixed copy, never the RPC's
|
|
11
|
+
// ChannelConversation row, and claims nothing the platform cannot
|
|
12
|
+
// keep (no console claim until the T04+ surface renders attention);
|
|
13
|
+
// - the conversation-own error mapper passes domain messages verbatim
|
|
14
|
+
// as {error, code} JSON — including NOT_FOUND, this domain's
|
|
15
|
+
// deliberate addition — while transport errors delegate to the
|
|
16
|
+
// shared classifier;
|
|
17
|
+
// - the zod bounds mirror the protovalidate constraints, refusing an
|
|
18
|
+
// empty or over-budget reason before any RPC.
|
|
19
|
+
|
|
20
|
+
import { create } from "@bufbuild/protobuf";
|
|
21
|
+
import { Code, ConnectError, type ConnectRouter } from "@connectrpc/connect";
|
|
22
|
+
import { connectNodeAdapter } from "@connectrpc/connect-node";
|
|
23
|
+
import {
|
|
24
|
+
createServer as createHttp2Server,
|
|
25
|
+
type Http2Server,
|
|
26
|
+
type ServerHttp2Session,
|
|
27
|
+
} from "node:http2";
|
|
28
|
+
import type { AddressInfo } from "node:net";
|
|
29
|
+
|
|
30
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
31
|
+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
32
|
+
import { ChannelConversationCommandController } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/conversation_command_pb";
|
|
33
|
+
import {
|
|
34
|
+
ChannelConversationSchema,
|
|
35
|
+
type EscalateConversationInput,
|
|
36
|
+
} from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/conversation_io_pb";
|
|
37
|
+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
38
|
+
|
|
39
|
+
import { configureLogger } from "../../logger";
|
|
40
|
+
import { CONVERSATION_ROUTE, createConversationServer } from "../../server";
|
|
41
|
+
|
|
42
|
+
configureLogger({ level: "error", format: "text" });
|
|
43
|
+
|
|
44
|
+
let backend: Http2Server;
|
|
45
|
+
let client: Client;
|
|
46
|
+
const openSessions = new Set<ServerHttp2Session>();
|
|
47
|
+
|
|
48
|
+
/** The next stubbed outcome; tests set this. */
|
|
49
|
+
let escalateResponse: () => ReturnType<typeof create<typeof ChannelConversationSchema>>;
|
|
50
|
+
|
|
51
|
+
/** Requests the stub captured (object property for closure narrowing). */
|
|
52
|
+
const captured: { escalate?: EscalateConversationInput } = {};
|
|
53
|
+
|
|
54
|
+
interface ToolResult {
|
|
55
|
+
content: Array<{ type: string; text?: string }>;
|
|
56
|
+
isError?: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function escalate(args: Record<string, unknown>): Promise<ToolResult> {
|
|
60
|
+
return (await client.callTool({
|
|
61
|
+
name: "escalate_to_human",
|
|
62
|
+
arguments: args,
|
|
63
|
+
})) as ToolResult;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
beforeAll(async () => {
|
|
67
|
+
const routes = (router: ConnectRouter) => {
|
|
68
|
+
router.service(ChannelConversationCommandController, {
|
|
69
|
+
escalate: (req) => {
|
|
70
|
+
captured.escalate = req;
|
|
71
|
+
return escalateResponse();
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
};
|
|
75
|
+
backend = createHttp2Server(connectNodeAdapter({ routes }));
|
|
76
|
+
backend.on("session", (session) => {
|
|
77
|
+
openSessions.add(session);
|
|
78
|
+
session.on("close", () => openSessions.delete(session));
|
|
79
|
+
});
|
|
80
|
+
await new Promise<void>((resolve) => backend.listen(0, "127.0.0.1", resolve));
|
|
81
|
+
const port = (backend.address() as AddressInfo).port;
|
|
82
|
+
|
|
83
|
+
const mcp = createConversationServer({ serverAddress: `127.0.0.1:${port}`, apiKey: "" });
|
|
84
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
85
|
+
client = new Client({ name: "conversation-integration", version: "test" });
|
|
86
|
+
await Promise.all([mcp.connect(serverTransport), client.connect(clientTransport)]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
afterAll(async () => {
|
|
90
|
+
await client?.close();
|
|
91
|
+
for (const session of openSessions) session.destroy();
|
|
92
|
+
await new Promise<void>((resolve) => backend.close(() => resolve()));
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("conversation roster (DD-008 D-c / A14)", () => {
|
|
96
|
+
it("exposes exactly escalate_to_human with a single reason argument", async () => {
|
|
97
|
+
const { tools } = await client.listTools();
|
|
98
|
+
expect(tools.map((t) => t.name)).toEqual(["escalate_to_human"]);
|
|
99
|
+
|
|
100
|
+
// The conversation identity is server-derived from the session
|
|
101
|
+
// credential's channel labels (the DD-003 identity doctrine), so
|
|
102
|
+
// reason is the ONLY argument — a channel or conversation id here
|
|
103
|
+
// would be a caller-supplied identity the reach must never trust.
|
|
104
|
+
const properties = (tools[0].inputSchema as { properties?: Record<string, unknown> })
|
|
105
|
+
.properties;
|
|
106
|
+
expect(Object.keys(properties ?? {})).toEqual(["reason"]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("pins the cross-repo route string (the TOOL_CALL_LIMIT precedent)", () => {
|
|
110
|
+
// The runner's conversation-attachment.ts builds the URL
|
|
111
|
+
// independently (shared/conversation-attachment.ts and its route
|
|
112
|
+
// pin); a drift here strands every synthesized attachment on a 404.
|
|
113
|
+
expect(CONVERSATION_ROUTE).toBe("/conversation");
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("the success answer is the A15 copy, not the row", () => {
|
|
118
|
+
it("sends the reason verbatim and answers with fixed copy", async () => {
|
|
119
|
+
escalateResponse = () =>
|
|
120
|
+
create(ChannelConversationSchema, {
|
|
121
|
+
agentChannelId: "agch_1",
|
|
122
|
+
conversationKey: "919000000001",
|
|
123
|
+
needsAttention: true,
|
|
124
|
+
attentionReason: "customer wants a refund decision",
|
|
125
|
+
});
|
|
126
|
+
captured.escalate = undefined;
|
|
127
|
+
|
|
128
|
+
const result = await escalate({ reason: "customer wants a refund decision" });
|
|
129
|
+
|
|
130
|
+
expect(result.isError).toBeFalsy();
|
|
131
|
+
const req = captured.escalate as EscalateConversationInput | undefined;
|
|
132
|
+
expect(req?.reason).toBe("customer wants a refund decision");
|
|
133
|
+
|
|
134
|
+
const text = result.content[0]?.text ?? "";
|
|
135
|
+
expect(text).toContain("recorded on this conversation");
|
|
136
|
+
// A15: the answer instructs against the promise at the moment of
|
|
137
|
+
// temptation — the model's next message to the customer.
|
|
138
|
+
expect(text).toContain("do not");
|
|
139
|
+
// The row must never leak back: its control state and conversation
|
|
140
|
+
// key are internal, and reflecting them invites the model to
|
|
141
|
+
// narrate platform state to the customer.
|
|
142
|
+
expect(text).not.toContain("919000000001");
|
|
143
|
+
expect(text).not.toContain("needs_attention");
|
|
144
|
+
// No console surface renders attention yet (T04+); the copy must
|
|
145
|
+
// not claim one. Delete this pin when the Conversations surface
|
|
146
|
+
// ships and the copy gains the claim.
|
|
147
|
+
expect(text.toLowerCase()).not.toContain("console");
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe("zod bounds mirror the protovalidate constraints", () => {
|
|
152
|
+
it("refuses an empty and an over-budget reason before any RPC", async () => {
|
|
153
|
+
captured.escalate = undefined;
|
|
154
|
+
|
|
155
|
+
const empty = await escalate({ reason: "" });
|
|
156
|
+
expect(empty.isError).toBe(true);
|
|
157
|
+
|
|
158
|
+
const overBudget = await escalate({ reason: "x".repeat(1025) });
|
|
159
|
+
expect(overBudget.isError).toBe(true);
|
|
160
|
+
|
|
161
|
+
expect(captured.escalate).toBeUndefined();
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe("conversation error mapper", () => {
|
|
166
|
+
it("passes a reach denial through verbatim — never the shared rewrite", async () => {
|
|
167
|
+
escalateResponse = () => {
|
|
168
|
+
throw new ConnectError(
|
|
169
|
+
"escalate is agent-audience only: it requires a session-scoped runner credential",
|
|
170
|
+
Code.PermissionDenied,
|
|
171
|
+
);
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const result = await escalate({ reason: "needs a human" });
|
|
175
|
+
|
|
176
|
+
expect(result.isError).toBe(true);
|
|
177
|
+
expect(JSON.parse(result.content[0]?.text ?? "{}")).toEqual({
|
|
178
|
+
error: "escalate is agent-audience only: it requires a session-scoped runner credential",
|
|
179
|
+
code: "PERMISSION_DENIED",
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("passes the OSS refusal through verbatim as FAILED_PRECONDITION", async () => {
|
|
184
|
+
escalateResponse = () => {
|
|
185
|
+
throw new ConnectError(
|
|
186
|
+
"conversation participation requires Stigmer Cloud",
|
|
187
|
+
Code.FailedPrecondition,
|
|
188
|
+
);
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const result = await escalate({ reason: "needs a human" });
|
|
192
|
+
|
|
193
|
+
expect(result.isError).toBe(true);
|
|
194
|
+
expect(JSON.parse(result.content[0]?.text ?? "{}")).toEqual({
|
|
195
|
+
error: "conversation participation requires Stigmer Cloud",
|
|
196
|
+
code: "FAILED_PRECONDITION",
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("passes NOT_FOUND through verbatim — this domain's deliberate addition", async () => {
|
|
201
|
+
// The shared classifier rewrites NotFound into "Verify the org and
|
|
202
|
+
// slug are correct" — advice naming arguments escalate does not
|
|
203
|
+
// take. The domain set includes NotFound so the handler's honest
|
|
204
|
+
// answer reaches the model.
|
|
205
|
+
escalateResponse = () => {
|
|
206
|
+
throw new ConnectError(
|
|
207
|
+
"no conversation with this key exists on channel agch_1",
|
|
208
|
+
Code.NotFound,
|
|
209
|
+
);
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const result = await escalate({ reason: "needs a human" });
|
|
213
|
+
|
|
214
|
+
expect(result.isError).toBe(true);
|
|
215
|
+
expect(JSON.parse(result.content[0]?.text ?? "{}")).toEqual({
|
|
216
|
+
error: "no conversation with this key exists on channel agch_1",
|
|
217
|
+
code: "NOT_FOUND",
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("delegates transport codes to the shared classifier", async () => {
|
|
222
|
+
escalateResponse = () => {
|
|
223
|
+
throw new ConnectError("upstream down", Code.Unavailable);
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const result = await escalate({ reason: "needs a human" });
|
|
227
|
+
|
|
228
|
+
expect(result.isError).toBe(true);
|
|
229
|
+
expect(result.content[0]?.text).toBe(
|
|
230
|
+
"Stigmer server is unavailable. Ensure it is running and reachable.",
|
|
231
|
+
);
|
|
232
|
+
});
|
|
233
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// The conversation-domain error mapper — the channels-domain sibling
|
|
2
|
+
// (DD-004 S-7's recorded posture: siblings share the idiom, never an
|
|
3
|
+
// abstraction over it; each domain documents its own contract).
|
|
4
|
+
//
|
|
5
|
+
// Conversation domain errors carry agent-relayable messages that are
|
|
6
|
+
// contract bytes (the ChannelConversationReach refusal matrix):
|
|
7
|
+
// "escalate is agent-audience only: it requires a session-scoped runner
|
|
8
|
+
// credential", "conversation identity could not be verified for this
|
|
9
|
+
// session", "this session is not serving a channel conversation, so
|
|
10
|
+
// there is nothing to escalate", the OSS "conversation participation
|
|
11
|
+
// requires Stigmer Cloud". The shared ../rpcerr.ts would rewrite them
|
|
12
|
+
// into transport advice; here the domain codes pass the server's
|
|
13
|
+
// message through VERBATIM as isError JSON `{error, code}` so the model
|
|
14
|
+
// can stop and adapt.
|
|
15
|
+
//
|
|
16
|
+
// Two deliberate differences from the channels set, each this domain's
|
|
17
|
+
// own contract:
|
|
18
|
+
// - NOT_FOUND is a domain code HERE: the handler's "no conversation
|
|
19
|
+
// with this key exists on channel …" is contract copy, and the
|
|
20
|
+
// shared classifier's rewrite ("Verify the org and slug are
|
|
21
|
+
// correct") names arguments escalate does not take.
|
|
22
|
+
// - No google.rpc.ErrorInfo extraction: the conversation commands
|
|
23
|
+
// attach none — the message alone carries the contract.
|
|
24
|
+
|
|
25
|
+
import { Code, ConnectError } from "@connectrpc/connect";
|
|
26
|
+
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
|
27
|
+
import { rpcError } from "../rpcerr.js";
|
|
28
|
+
import { errorResult, textResult } from "../toolresult.js";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The gRPC codes whose messages are the conversation domain contract:
|
|
32
|
+
* reach denials (PERMISSION_DENIED, one fail-closed text per failure
|
|
33
|
+
* class), contract misuse with corrective copy (INVALID_ARGUMENT — an
|
|
34
|
+
* empty or over-budget reason), actionable preconditions
|
|
35
|
+
* (FAILED_PRECONDITION — the OSS refusal, a session serving no channel
|
|
36
|
+
* conversation), and the absent-conversation answer (NOT_FOUND).
|
|
37
|
+
*/
|
|
38
|
+
const DOMAIN_CODES: ReadonlySet<Code> = new Set([
|
|
39
|
+
Code.PermissionDenied,
|
|
40
|
+
Code.InvalidArgument,
|
|
41
|
+
Code.FailedPrecondition,
|
|
42
|
+
Code.NotFound,
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
/** The structured error payload conversation tools return for domain errors. */
|
|
46
|
+
export interface ConversationToolError {
|
|
47
|
+
/** The server's relayable message, byte-for-byte. */
|
|
48
|
+
error: string;
|
|
49
|
+
/** gRPC status name in SCREAMING_SNAKE (e.g. PERMISSION_DENIED). */
|
|
50
|
+
code: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Run a conversation-tool body: a string payload becomes a text result;
|
|
55
|
+
* a domain error becomes an isError JSON result carrying the verbatim
|
|
56
|
+
* message; a transport error delegates to the shared classifier. The
|
|
57
|
+
* only try/catch in this domain.
|
|
58
|
+
*/
|
|
59
|
+
export async function conversationResult(
|
|
60
|
+
toolContext: string,
|
|
61
|
+
produce: () => Promise<string>,
|
|
62
|
+
): Promise<CallToolResult> {
|
|
63
|
+
try {
|
|
64
|
+
return textResult(await produce());
|
|
65
|
+
} catch (err) {
|
|
66
|
+
const ce = ConnectError.from(err);
|
|
67
|
+
if (DOMAIN_CODES.has(ce.code)) {
|
|
68
|
+
return {
|
|
69
|
+
content: [{ type: "text", text: JSON.stringify(domainError(ce)) }],
|
|
70
|
+
isError: true,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
return errorResult(rpcError(ce, toolContext));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Project a domain ConnectError into the structured tool payload. */
|
|
78
|
+
export function domainError(ce: ConnectError): ConversationToolError {
|
|
79
|
+
return {
|
|
80
|
+
error: ce.rawMessage,
|
|
81
|
+
code: grpcStatusName(ce.code),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Connect's PascalCase code name → the gRPC SCREAMING_SNAKE status name. */
|
|
86
|
+
function grpcStatusName(code: Code): string {
|
|
87
|
+
return Code[code].replace(/([a-z0-9])([A-Z])/g, "$1_$2").toUpperCase();
|
|
88
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// The escalate_to_human tool — the ONE tool of the conversation roster
|
|
2
|
+
// (channel-conversations DD-008 D-b). A14 named the roster
|
|
3
|
+
// stigmer-conversation, axis-named like stigmer-channels: the
|
|
4
|
+
// conditioning axis is "this session IS a live channel conversation",
|
|
5
|
+
// and the notes/loop-in tools DD-008 anticipates join this roster
|
|
6
|
+
// instead of earning new routes.
|
|
7
|
+
//
|
|
8
|
+
// Agent audience only, by construction: escalate derives the
|
|
9
|
+
// conversation identity server-side from the session-scoped credential
|
|
10
|
+
// (the DD-003 identity doctrine), so a direct principal is always
|
|
11
|
+
// refused PERMISSION_DENIED — which is why no variant exists on the
|
|
12
|
+
// full roster and why the input surface is a single `reason`.
|
|
13
|
+
//
|
|
14
|
+
// The tool's answer is FIXED COPY, never the RPC's ChannelConversation
|
|
15
|
+
// row — a deliberate divergence from the channels roster's
|
|
16
|
+
// verbatim-proto-JSON convention. A15 rules the result states only what
|
|
17
|
+
// the platform can keep at 3am (attention is a stored flag; no one is
|
|
18
|
+
// paged; no console surface renders it yet) and instructs the agent
|
|
19
|
+
// never to promise a human or a response time.
|
|
20
|
+
|
|
21
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
22
|
+
import { z } from "zod";
|
|
23
|
+
|
|
24
|
+
import { resolveToken, type BackendTarget } from "../client.js";
|
|
25
|
+
import { escalateConversation } from "./calls.js";
|
|
26
|
+
import { conversationResult } from "./errors.js";
|
|
27
|
+
|
|
28
|
+
const ESCALATE_DESCRIPTION = [
|
|
29
|
+
"Flag this conversation for a human teammate to look at. Use this when you " +
|
|
30
|
+
"cannot resolve the customer's request yourself: you lack the information, " +
|
|
31
|
+
"the request needs a decision you are not authorized to make, or the " +
|
|
32
|
+
"customer has asked for a person.",
|
|
33
|
+
"You keep serving the conversation after calling this. Nothing is handed " +
|
|
34
|
+
"off automatically and no one is paged. Do not tell the customer a human " +
|
|
35
|
+
"will reply, and never promise a response time.",
|
|
36
|
+
"Write the reason for a teammate who has not read the conversation. " +
|
|
37
|
+
"Calling this again with a new reason is safe; the latest reason is what " +
|
|
38
|
+
"they see.",
|
|
39
|
+
].join("\n\n");
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The success answer (A15): states only recorded fact and repeats the
|
|
43
|
+
* no-promise instruction at the moment of temptation — the model's next
|
|
44
|
+
* message to the customer. Deliberately does NOT claim the flag "shows
|
|
45
|
+
* in the team's console": no console surface renders needs_attention
|
|
46
|
+
* yet (the Conversations surface is T04+); add the claim when it ships.
|
|
47
|
+
*/
|
|
48
|
+
export const ESCALATION_RECORDED_COPY =
|
|
49
|
+
"Flagged for human attention. Your reason was recorded on this " +
|
|
50
|
+
"conversation. Keep helping the customer as best you can, and do not " +
|
|
51
|
+
"tell the customer a human will reply or when.";
|
|
52
|
+
|
|
53
|
+
/** Register the conversation participation tool; returns the tool names. */
|
|
54
|
+
export function registerConversationTools(server: McpServer, target: BackendTarget): string[] {
|
|
55
|
+
server.registerTool(
|
|
56
|
+
"escalate_to_human",
|
|
57
|
+
{
|
|
58
|
+
description: ESCALATE_DESCRIPTION,
|
|
59
|
+
inputSchema: {
|
|
60
|
+
// Bounds mirror the protovalidate constraints on
|
|
61
|
+
// EscalateConversationInput.reason (conversation_io.proto:
|
|
62
|
+
// min_len 1, max_len 1024) so the model gets corrective feedback
|
|
63
|
+
// without an RPC; the server's validation is the backstop.
|
|
64
|
+
reason: z
|
|
65
|
+
.string()
|
|
66
|
+
.min(1)
|
|
67
|
+
.max(1024)
|
|
68
|
+
.describe(
|
|
69
|
+
"Why you are escalating, written for a teammate who has not read " +
|
|
70
|
+
"the conversation. Staff see it as the conversation's attention " +
|
|
71
|
+
"reason. 1-1024 characters.",
|
|
72
|
+
),
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
(args, extra) =>
|
|
76
|
+
conversationResult("escalation", async () => {
|
|
77
|
+
await escalateConversation(
|
|
78
|
+
target.serverAddress,
|
|
79
|
+
resolveToken(extra, target.apiKey),
|
|
80
|
+
args.reason,
|
|
81
|
+
);
|
|
82
|
+
return ESCALATION_RECORDED_COPY;
|
|
83
|
+
}),
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
return ["escalate_to_human"];
|
|
87
|
+
}
|