@checkstack/ai-backend 0.1.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/CHANGELOG.md +97 -0
- package/drizzle/0000_productive_jackpot.sql +26 -0
- package/drizzle/0001_puzzling_purple_man.sql +26 -0
- package/drizzle/0002_sparkling_paper_doll.sql +15 -0
- package/drizzle/0003_married_senator_kelly.sql +1 -0
- package/drizzle/0004_crazy_miek.sql +2 -0
- package/drizzle/0005_tearful_randall_flagg.sql +1 -0
- package/drizzle/meta/0000_snapshot.json +232 -0
- package/drizzle/meta/0001_snapshot.json +434 -0
- package/drizzle/meta/0002_snapshot.json +551 -0
- package/drizzle/meta/0003_snapshot.json +557 -0
- package/drizzle/meta/0004_snapshot.json +573 -0
- package/drizzle/meta/0005_snapshot.json +574 -0
- package/drizzle/meta/_journal.json +48 -0
- package/drizzle.config.ts +7 -0
- package/package.json +42 -0
- package/src/agent-runner.test.ts +262 -0
- package/src/agent-runner.ts +262 -0
- package/src/chat/agent-loop.test.ts +119 -0
- package/src/chat/agent-loop.ts +73 -0
- package/src/chat/auto-apply.test.ts +237 -0
- package/src/chat/chat-handler.ts +111 -0
- package/src/chat/chat-service.streamturn.test.ts +417 -0
- package/src/chat/chat-service.test.ts +250 -0
- package/src/chat/chat-service.ts +923 -0
- package/src/chat/classifier-service.ts +64 -0
- package/src/chat/classifier.logic.test.ts +92 -0
- package/src/chat/classifier.logic.ts +71 -0
- package/src/chat/conversation-store.it.test.ts +203 -0
- package/src/chat/conversation-store.test.ts +248 -0
- package/src/chat/conversation-store.ts +237 -0
- package/src/chat/decision.logic.test.ts +45 -0
- package/src/chat/decision.logic.ts +54 -0
- package/src/chat/llm-provider.test.ts +63 -0
- package/src/chat/llm-provider.ts +67 -0
- package/src/chat/model-error.logic.test.ts +60 -0
- package/src/chat/model-error.logic.ts +65 -0
- package/src/chat/normalize-messages.logic.test.ts +101 -0
- package/src/chat/normalize-messages.logic.ts +65 -0
- package/src/chat/permission-mode.logic.test.ts +70 -0
- package/src/chat/permission-mode.logic.ts +45 -0
- package/src/chat/read-invoker.ts +72 -0
- package/src/chat/replay.test.ts +174 -0
- package/src/chat/scrub-content.test.ts +183 -0
- package/src/chat/scrub-content.ts +154 -0
- package/src/chat/sdk-tools.test.ts +168 -0
- package/src/chat/sdk-tools.ts +181 -0
- package/src/chat/title-service.test.ts +146 -0
- package/src/chat/title-service.ts +111 -0
- package/src/chat/title.logic.test.ts +98 -0
- package/src/chat/title.logic.ts +102 -0
- package/src/extension-points.ts +41 -0
- package/src/generated/docs-index.ts +3020 -0
- package/src/hardening/handler-authz.test.ts +282 -0
- package/src/hardening/no-secret-leak.test.ts +303 -0
- package/src/hooks.ts +33 -0
- package/src/index.ts +542 -0
- package/src/mcp/connection-registry.test.ts +25 -0
- package/src/mcp/connection-registry.ts +54 -0
- package/src/mcp/mcp-conformance.it.test.ts +128 -0
- package/src/mcp/server.test.ts +285 -0
- package/src/mcp/server.ts +300 -0
- package/src/mcp/tool-invoker.ts +65 -0
- package/src/openai-provider.test.ts +64 -0
- package/src/openai-provider.ts +146 -0
- package/src/projection.test.ts +97 -0
- package/src/projection.ts +132 -0
- package/src/propose-apply/args-hash.test.ts +26 -0
- package/src/propose-apply/args-hash.ts +30 -0
- package/src/propose-apply/service.test.ts +423 -0
- package/src/propose-apply/service.ts +419 -0
- package/src/propose-apply/store.test.ts +136 -0
- package/src/propose-apply/store.ts +224 -0
- package/src/propose-apply/token.test.ts +52 -0
- package/src/propose-apply/token.ts +71 -0
- package/src/rate-limit/spend-ledger.it.test.ts +224 -0
- package/src/rate-limit/spend-ledger.test.ts +176 -0
- package/src/rate-limit/spend-ledger.ts +162 -0
- package/src/rate-limit/tool-budget.it.test.ts +173 -0
- package/src/rate-limit/tool-budget.test.ts +58 -0
- package/src/rate-limit/tool-budget.ts +107 -0
- package/src/registry-wiring.test.ts +131 -0
- package/src/registry-wiring.ts +68 -0
- package/src/resolver.test.ts +156 -0
- package/src/resolver.ts +78 -0
- package/src/router.test.ts +78 -0
- package/src/router.ts +345 -0
- package/src/schema.ts +284 -0
- package/src/serializer.test.ts +88 -0
- package/src/serializer.ts +42 -0
- package/src/tool-registry.ts +58 -0
- package/src/tools/composite-tools.ts +24 -0
- package/src/tools/docs-tools.test.ts +150 -0
- package/src/tools/docs-tools.ts +115 -0
- package/src/tools/probe-url.test.ts +51 -0
- package/src/tools/probe-url.ts +146 -0
- package/src/tools/rank-docs.test.ts +153 -0
- package/src/tools/rank-docs.ts +209 -0
- package/src/tools/script-context-extract.test.ts +93 -0
- package/src/tools/script-context-extract.ts +283 -0
- package/src/tools/ssrf-guard.test.ts +69 -0
- package/src/tools/ssrf-guard.ts +108 -0
- package/src/tools/tool-set.e2e.test.ts +64 -0
- package/src/user-rpc-client.test.ts +45 -0
- package/src/user-rpc-client.ts +60 -0
- package/tsconfig.json +26 -0
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { extractErrorMessage } from "@checkstack/common";
|
|
3
|
+
import {
|
|
4
|
+
opaqueBearerToken,
|
|
5
|
+
type AuthService,
|
|
6
|
+
type AuthUser,
|
|
7
|
+
} from "@checkstack/backend-api";
|
|
8
|
+
import { serializeTools } from "../serializer";
|
|
9
|
+
import { hashToolArgs } from "../propose-apply/args-hash";
|
|
10
|
+
import type { AiToolResolver } from "../resolver";
|
|
11
|
+
import type { RegisteredAiTool } from "../tool-registry";
|
|
12
|
+
import type { McpToolInvoker } from "./tool-invoker";
|
|
13
|
+
import type { McpConnectionRegistry } from "./connection-registry";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A read-only tool exposed over MCP, bound to the live oRPC procedure that
|
|
17
|
+
* actually runs it. The `tool` carries the model-facing schema + the access
|
|
18
|
+
* rules the resolver filters on; `pluginId` / `procedureKey` route the
|
|
19
|
+
* `tools/call` re-entry through the live router (handler authz preserved).
|
|
20
|
+
*/
|
|
21
|
+
export interface McpExecutableTool {
|
|
22
|
+
tool: RegisteredAiTool;
|
|
23
|
+
pluginId: string;
|
|
24
|
+
procedureKey: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const JSONRPC_VERSION = "2.0";
|
|
28
|
+
const MCP_PROTOCOL_VERSION = "2025-06-18";
|
|
29
|
+
const MCP_SESSION_HEADER = "mcp-session-id";
|
|
30
|
+
|
|
31
|
+
/** JSON-RPC 2.0 + MCP error codes (named to avoid magic numbers). */
|
|
32
|
+
const RPC_PARSE_ERROR = -32_700;
|
|
33
|
+
const RPC_METHOD_NOT_FOUND = -32_601;
|
|
34
|
+
const RPC_INVALID_PARAMS = -32_602;
|
|
35
|
+
const RPC_UNAUTHORIZED = -32_001;
|
|
36
|
+
const RPC_FORBIDDEN = -32_003;
|
|
37
|
+
/** Custom application error for an exceeded per-principal tool budget (§14.5). */
|
|
38
|
+
const RPC_RATE_LIMITED = -32_010;
|
|
39
|
+
|
|
40
|
+
/** The subset of an AuthUser the budget keys on. */
|
|
41
|
+
interface BudgetPrincipal {
|
|
42
|
+
kind: "user" | "application";
|
|
43
|
+
id: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Per-principal tool-budget enforcer (§14.5). Resolves when the call is within
|
|
48
|
+
* budget; rejects with a {@link Error} when over budget. Optional so the
|
|
49
|
+
* handler stays testable without a Postgres connection.
|
|
50
|
+
*/
|
|
51
|
+
export type McpBudgetEnforcer = (
|
|
52
|
+
principal: BudgetPrincipal,
|
|
53
|
+
) => Promise<void>;
|
|
54
|
+
|
|
55
|
+
/** Records a directly-executed read tool call into the audit log (optional). */
|
|
56
|
+
export type McpRecordExecuted = (args: {
|
|
57
|
+
principal: BudgetPrincipal;
|
|
58
|
+
toolName: string;
|
|
59
|
+
argsHash: string;
|
|
60
|
+
}) => Promise<void>;
|
|
61
|
+
|
|
62
|
+
interface JsonRpcRequest {
|
|
63
|
+
jsonrpc: string;
|
|
64
|
+
id?: string | number | null;
|
|
65
|
+
method: string;
|
|
66
|
+
params?: Record<string, unknown>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
type JsonRpcId = string | number | null;
|
|
70
|
+
|
|
71
|
+
function rpcResult(id: JsonRpcId, result: unknown) {
|
|
72
|
+
return { jsonrpc: JSONRPC_VERSION, id, result };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function rpcError(id: JsonRpcId, code: number, message: string) {
|
|
76
|
+
return { jsonrpc: JSONRPC_VERSION, id, error: { code, message } };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function jsonResponse(body: unknown, init?: ResponseInit): Response {
|
|
80
|
+
return Response.json(body, {
|
|
81
|
+
status: init?.status ?? 200,
|
|
82
|
+
headers: init?.headers,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Build the Streamable-HTTP MCP request handler (decision §9 — Streamable HTTP,
|
|
88
|
+
* NOT the deprecated HTTP+SSE). Handles the read-only surface: `initialize`,
|
|
89
|
+
* `tools/list`, `tools/call`, and the `notifications/initialized` ack.
|
|
90
|
+
*
|
|
91
|
+
* Authentication: the bearer OAuth token is resolved to a NARROWED principal by
|
|
92
|
+
* the platform auth strategy (`auth.authenticate` → the Bearer-OAuth branch).
|
|
93
|
+
* Tool visibility is filtered by the resolver (the principal only sees tools it
|
|
94
|
+
* may call); `tools/call` re-enters the live router as that principal so the
|
|
95
|
+
* handler re-checks authz. The model never bypasses authorization.
|
|
96
|
+
*/
|
|
97
|
+
export function createMcpRequestHandler({
|
|
98
|
+
tools,
|
|
99
|
+
resolver,
|
|
100
|
+
invoker,
|
|
101
|
+
auth,
|
|
102
|
+
connections,
|
|
103
|
+
enforceBudget,
|
|
104
|
+
recordExecuted,
|
|
105
|
+
}: {
|
|
106
|
+
tools: McpExecutableTool[];
|
|
107
|
+
resolver: AiToolResolver;
|
|
108
|
+
invoker: McpToolInvoker;
|
|
109
|
+
auth: AuthService;
|
|
110
|
+
connections: McpConnectionRegistry;
|
|
111
|
+
/** Per-principal tool-budget enforcer (§14.5). Refuses over-budget calls. */
|
|
112
|
+
enforceBudget?: McpBudgetEnforcer;
|
|
113
|
+
/** Audit-record a directly-executed read tool (matrix #13). */
|
|
114
|
+
recordExecuted?: McpRecordExecuted;
|
|
115
|
+
}): (req: Request) => Promise<Response> {
|
|
116
|
+
// Built lazily on first request: the `tools` array is populated in
|
|
117
|
+
// `afterPluginsReady` (once every plugin has exposed its read projections),
|
|
118
|
+
// which runs AFTER this handler is created in init. Requests only arrive after
|
|
119
|
+
// that phase, so the first request sees the complete set.
|
|
120
|
+
let byName: Map<string, McpExecutableTool> | undefined;
|
|
121
|
+
|
|
122
|
+
return async function handleMcpRequest(req: Request): Promise<Response> {
|
|
123
|
+
byName ??= new Map(tools.map((t) => [t.tool.name, t]));
|
|
124
|
+
if (req.method === "GET") {
|
|
125
|
+
// No server-initiated SSE stream for the read-only surface; clients use
|
|
126
|
+
// POST request/response. (A GET opens an SSE channel in full MCP; not
|
|
127
|
+
// needed for read-only tools.)
|
|
128
|
+
return new Response(null, { status: 405 });
|
|
129
|
+
}
|
|
130
|
+
if (req.method !== "POST") {
|
|
131
|
+
return new Response(null, { status: 405 });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const bearerToken = opaqueBearerToken(req);
|
|
135
|
+
const principal: AuthUser | undefined = await auth.authenticate(req);
|
|
136
|
+
|
|
137
|
+
let payload: JsonRpcRequest;
|
|
138
|
+
try {
|
|
139
|
+
payload = (await req.json()) as JsonRpcRequest;
|
|
140
|
+
} catch {
|
|
141
|
+
return jsonResponse(rpcError(null, RPC_PARSE_ERROR, "Parse error"), {
|
|
142
|
+
status: 400,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const id = payload.id ?? null;
|
|
147
|
+
|
|
148
|
+
switch (payload.method) {
|
|
149
|
+
case "initialize": {
|
|
150
|
+
// A fresh session id is minted and tracked pod-locally (bookkeeping).
|
|
151
|
+
const sessionId = randomUUID();
|
|
152
|
+
if (principal) {
|
|
153
|
+
connections.open({
|
|
154
|
+
sessionId,
|
|
155
|
+
principalId: "id" in principal ? principal.id : "unknown",
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
return jsonResponse(
|
|
159
|
+
rpcResult(id, {
|
|
160
|
+
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
161
|
+
capabilities: { tools: { listChanged: false } },
|
|
162
|
+
serverInfo: { name: "checkstack", version: "1.0.0" },
|
|
163
|
+
}),
|
|
164
|
+
{ headers: { [MCP_SESSION_HEADER]: sessionId } },
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
case "notifications/initialized": {
|
|
169
|
+
// Notification: no response body (id is absent).
|
|
170
|
+
return new Response(null, { status: 202 });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
case "tools/list": {
|
|
174
|
+
if (!principal) {
|
|
175
|
+
return jsonResponse(rpcError(id, RPC_UNAUTHORIZED, "Unauthorized"), {
|
|
176
|
+
status: 401,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
// Only read-effect tools are listed: the read-only MCP surface invokes
|
|
180
|
+
// tools via a bare `tools/call`, which the structural effect-gate
|
|
181
|
+
// refuses for mutate/destructive tools. Listing a tool that could only
|
|
182
|
+
// ever be refused would mislead the model, so they are filtered here
|
|
183
|
+
// (the propose/apply flow surfaces mutating tools separately).
|
|
184
|
+
const allowed = resolver
|
|
185
|
+
.resolveTools(principal)
|
|
186
|
+
.filter((tool) => tool.effect === "read");
|
|
187
|
+
const descriptors = serializeTools({ tools: allowed }).map((d) => ({
|
|
188
|
+
name: d.name,
|
|
189
|
+
description: d.description,
|
|
190
|
+
inputSchema: d.inputSchema,
|
|
191
|
+
}));
|
|
192
|
+
return jsonResponse(rpcResult(id, { tools: descriptors }));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
case "tools/call": {
|
|
196
|
+
if (!principal || !bearerToken) {
|
|
197
|
+
return jsonResponse(rpcError(id, RPC_UNAUTHORIZED, "Unauthorized"), {
|
|
198
|
+
status: 401,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
const name = payload.params?.name;
|
|
202
|
+
if (typeof name !== "string") {
|
|
203
|
+
return jsonResponse(
|
|
204
|
+
rpcError(id, RPC_INVALID_PARAMS, "Invalid params: missing tool name"),
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
const executable = byName.get(name);
|
|
208
|
+
if (!executable) {
|
|
209
|
+
return jsonResponse(
|
|
210
|
+
rpcError(id, RPC_INVALID_PARAMS, `Unknown tool: ${name}`),
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
// Resolver gate: the model can only call a tool the principal may see.
|
|
214
|
+
// (Handler-side authz is the authority and re-runs below; this gate
|
|
215
|
+
// refuses a misbehaving model server-side BEFORE re-entry.)
|
|
216
|
+
if (!resolver.isAllowed({ principal, tool: executable.tool })) {
|
|
217
|
+
return jsonResponse(rpcError(id, RPC_FORBIDDEN, `Forbidden: ${name}`), {
|
|
218
|
+
status: 403,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
// STRUCTURAL effect-gate (decision §1.6 / §8): a bare `tools/call` may
|
|
222
|
+
// ONLY run a read-effect tool. Mutating / destructive tools MUST go
|
|
223
|
+
// through the two-step propose -> apply flow (the proposal token is the
|
|
224
|
+
// single-use consent gate), never a direct invocation. This makes the
|
|
225
|
+
// read-only-over-MCP guarantee a structural property of the handler,
|
|
226
|
+
// independent of which tools happen to be wired into `tools`.
|
|
227
|
+
if (executable.tool.effect !== "read") {
|
|
228
|
+
return jsonResponse(
|
|
229
|
+
rpcError(
|
|
230
|
+
id,
|
|
231
|
+
RPC_FORBIDDEN,
|
|
232
|
+
`Tool "${name}" has effect "${executable.tool.effect}" and cannot be invoked via tools/call; use the propose/apply flow.`,
|
|
233
|
+
),
|
|
234
|
+
{ status: 403 },
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
const args =
|
|
238
|
+
(payload.params?.arguments as Record<string, unknown>) ?? {};
|
|
239
|
+
|
|
240
|
+
// Per-principal tool budget (§14.5): enforced BEFORE the call so an
|
|
241
|
+
// over-budget caller is refused. Shared-Postgres counter — cross-pod
|
|
242
|
+
// correct. The principal kind is "user" | "application" (services never
|
|
243
|
+
// reach here: they have no access rules and the resolver gate refused).
|
|
244
|
+
const budgetPrincipal: BudgetPrincipal | undefined =
|
|
245
|
+
principal.type === "user" || principal.type === "application"
|
|
246
|
+
? { kind: principal.type, id: principal.id }
|
|
247
|
+
: undefined;
|
|
248
|
+
if (enforceBudget && budgetPrincipal) {
|
|
249
|
+
try {
|
|
250
|
+
await enforceBudget(budgetPrincipal);
|
|
251
|
+
} catch (error) {
|
|
252
|
+
return jsonResponse(
|
|
253
|
+
rpcError(id, RPC_RATE_LIMITED, extractErrorMessage(error)),
|
|
254
|
+
{ status: 429 },
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
try {
|
|
260
|
+
const result = await invoker.invoke({
|
|
261
|
+
pluginId: executable.pluginId,
|
|
262
|
+
procedureKey: executable.procedureKey,
|
|
263
|
+
input: args,
|
|
264
|
+
bearerToken,
|
|
265
|
+
});
|
|
266
|
+
// Audit-record the directly-executed read tool (matrix #13). The args
|
|
267
|
+
// hash is recorded, never the raw args. Best-effort: an audit failure
|
|
268
|
+
// must not fail the tool call.
|
|
269
|
+
if (recordExecuted && budgetPrincipal) {
|
|
270
|
+
await recordExecuted({
|
|
271
|
+
principal: budgetPrincipal,
|
|
272
|
+
toolName: name,
|
|
273
|
+
argsHash: hashToolArgs(args),
|
|
274
|
+
}).catch(() => {});
|
|
275
|
+
}
|
|
276
|
+
return jsonResponse(
|
|
277
|
+
rpcResult(id, {
|
|
278
|
+
content: [{ type: "text", text: JSON.stringify(result) }],
|
|
279
|
+
isError: false,
|
|
280
|
+
}),
|
|
281
|
+
);
|
|
282
|
+
} catch (error) {
|
|
283
|
+
// Surfaced as an MCP tool error (not a transport error), per spec.
|
|
284
|
+
return jsonResponse(
|
|
285
|
+
rpcResult(id, {
|
|
286
|
+
content: [{ type: "text", text: extractErrorMessage(error) }],
|
|
287
|
+
isError: true,
|
|
288
|
+
}),
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
default: {
|
|
294
|
+
return jsonResponse(
|
|
295
|
+
rpcError(id, RPC_METHOD_NOT_FOUND, `Method not found: ${payload.method}`),
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { createORPCClient } from "@orpc/client";
|
|
2
|
+
import { RPCLink } from "@orpc/client/fetch";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Invokes a projected read-only tool's SOURCE oRPC procedure by re-entering the
|
|
6
|
+
* live router as the resolved principal, so handler-side authorization runs
|
|
7
|
+
* exactly as it would for any other caller (decision 5 — the model never
|
|
8
|
+
* bypasses authz; it is an untrusted caller that happens to pick arguments).
|
|
9
|
+
*
|
|
10
|
+
* The re-entry forwards the caller's OWN opaque OAuth bearer token to the
|
|
11
|
+
* loopback RPC endpoint. The API route handler re-authenticates that token
|
|
12
|
+
* through the Bearer-OAuth branch (introspect + narrow to the live principal)
|
|
13
|
+
* and runs `autoAuthMiddleware`. So the tool call is authorized as the NARROWED
|
|
14
|
+
* principal, server-side, on every call. We deliberately do NOT use the
|
|
15
|
+
* service-credential `fetch` (that would call as a trusted service and skip the
|
|
16
|
+
* user's authz).
|
|
17
|
+
*/
|
|
18
|
+
export interface McpToolInvoker {
|
|
19
|
+
invoke(args: {
|
|
20
|
+
/** The source procedure's owning plugin id (e.g. "incident"). */
|
|
21
|
+
pluginId: string;
|
|
22
|
+
/** The source procedure key (e.g. "listIncidents"). */
|
|
23
|
+
procedureKey: string;
|
|
24
|
+
/** Validated tool input. */
|
|
25
|
+
input: unknown;
|
|
26
|
+
/** The caller's opaque OAuth bearer token (forwarded verbatim). */
|
|
27
|
+
bearerToken: string;
|
|
28
|
+
}): Promise<unknown>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A minimal typed view of the loopback RPC client (per-plugin, per-procedure). */
|
|
32
|
+
type LoopbackClient = Record<
|
|
33
|
+
string,
|
|
34
|
+
Record<string, (input: unknown) => Promise<unknown>>
|
|
35
|
+
>;
|
|
36
|
+
|
|
37
|
+
export function createMcpToolInvoker({
|
|
38
|
+
internalUrl,
|
|
39
|
+
}: {
|
|
40
|
+
/** Base URL of this backend for the loopback call (e.g. INTERNAL_URL). */
|
|
41
|
+
internalUrl: string;
|
|
42
|
+
}): McpToolInvoker {
|
|
43
|
+
return {
|
|
44
|
+
async invoke({ pluginId, procedureKey, input, bearerToken }) {
|
|
45
|
+
// A fresh link per call carries this caller's bearer token; the live
|
|
46
|
+
// router re-checks authz as the narrowed principal.
|
|
47
|
+
const link = new RPCLink({
|
|
48
|
+
url: `${internalUrl}/api`,
|
|
49
|
+
headers: { authorization: `Bearer ${bearerToken}` },
|
|
50
|
+
});
|
|
51
|
+
const client = createORPCClient(link) as LoopbackClient;
|
|
52
|
+
const pluginClient = client[pluginId];
|
|
53
|
+
if (!pluginClient) {
|
|
54
|
+
throw new Error(`No RPC client for plugin "${pluginId}".`);
|
|
55
|
+
}
|
|
56
|
+
const procedure = pluginClient[procedureKey];
|
|
57
|
+
if (typeof procedure !== "function") {
|
|
58
|
+
throw new TypeError(
|
|
59
|
+
`Procedure "${pluginId}.${procedureKey}" is not callable.`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
return procedure(input);
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { OpenAiCompatibleConnectionSchema } from "./openai-provider";
|
|
3
|
+
|
|
4
|
+
/** Minimal valid connection sans spendCap; spread + override `spendCap` per case. */
|
|
5
|
+
const baseConfig = {
|
|
6
|
+
baseUrl: "https://openrouter.ai/api/v1",
|
|
7
|
+
apiKey: "sk-test",
|
|
8
|
+
defaultModel: "deepseek-v4-flash",
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
describe("OpenAiCompatibleConnectionSchema spendCap", () => {
|
|
12
|
+
it("accepts a connection with NO spendCap (unlimited)", () => {
|
|
13
|
+
const parsed = OpenAiCompatibleConnectionSchema.parse({ ...baseConfig });
|
|
14
|
+
expect(parsed.spendCap).toBeUndefined();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("treats an empty spendCap object as no cap", () => {
|
|
18
|
+
const parsed = OpenAiCompatibleConnectionSchema.parse({
|
|
19
|
+
...baseConfig,
|
|
20
|
+
spendCap: {},
|
|
21
|
+
});
|
|
22
|
+
expect(parsed.spendCap).toBeUndefined();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("treats blank-string spendCap fields as no cap", () => {
|
|
26
|
+
const parsed = OpenAiCompatibleConnectionSchema.parse({
|
|
27
|
+
...baseConfig,
|
|
28
|
+
spendCap: { tokenBudget: "", windowMinutes: "" },
|
|
29
|
+
});
|
|
30
|
+
expect(parsed.spendCap).toBeUndefined();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("treats an incomplete spendCap (one blank bound) as no cap", () => {
|
|
34
|
+
expect(
|
|
35
|
+
OpenAiCompatibleConnectionSchema.parse({
|
|
36
|
+
...baseConfig,
|
|
37
|
+
spendCap: { tokenBudget: 1000 },
|
|
38
|
+
}).spendCap,
|
|
39
|
+
).toBeUndefined();
|
|
40
|
+
expect(
|
|
41
|
+
OpenAiCompatibleConnectionSchema.parse({
|
|
42
|
+
...baseConfig,
|
|
43
|
+
spendCap: { windowMinutes: 60 },
|
|
44
|
+
}).spendCap,
|
|
45
|
+
).toBeUndefined();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("keeps a fully-specified spendCap", () => {
|
|
49
|
+
const parsed = OpenAiCompatibleConnectionSchema.parse({
|
|
50
|
+
...baseConfig,
|
|
51
|
+
spendCap: { tokenBudget: 1000, windowMinutes: 60 },
|
|
52
|
+
});
|
|
53
|
+
expect(parsed.spendCap).toEqual({ tokenBudget: 1000, windowMinutes: 60 });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("still rejects a present cap with a non-positive bound", () => {
|
|
57
|
+
expect(() =>
|
|
58
|
+
OpenAiCompatibleConnectionSchema.parse({
|
|
59
|
+
...baseConfig,
|
|
60
|
+
spendCap: { tokenBudget: 0, windowMinutes: 60 },
|
|
61
|
+
}),
|
|
62
|
+
).toThrow();
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { Versioned, configString, configNumber } from "@checkstack/backend-api";
|
|
3
|
+
import { extractErrorMessage } from "@checkstack/common";
|
|
4
|
+
import type {
|
|
5
|
+
IntegrationProvider,
|
|
6
|
+
TestConnectionResult,
|
|
7
|
+
} from "@checkstack/integration-backend";
|
|
8
|
+
import {
|
|
9
|
+
OPENAI_COMPATIBLE_DEFAULT_BASE_URL,
|
|
10
|
+
OPENAI_COMPATIBLE_PROVIDER_LOCAL_ID,
|
|
11
|
+
type OpenAiCompatibleConnection,
|
|
12
|
+
} from "@checkstack/ai-common";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Connection schema for an OpenAI-compatible LLM provider (OpenAI, Azure,
|
|
16
|
+
* OpenRouter, Ollama, vLLM, LM Studio, ...). Model choice lives on the
|
|
17
|
+
* connection (decision §14.6): `defaultModel` is required, `availableModels`
|
|
18
|
+
* optionally constrains the Phase 4 chat model picker.
|
|
19
|
+
*
|
|
20
|
+
* `apiKey` is marked `x-secret`, so the integration platform stores it in the
|
|
21
|
+
* Secrets Vault and redacts it from every UI / DTO response — the credential
|
|
22
|
+
* never leaves the backend.
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* Normalize the raw `spendCap` input before validation. A spend cap is OPTIONAL
|
|
26
|
+
* (absent = unlimited), and the schema-driven connection form always renders the
|
|
27
|
+
* nested `tokenBudget` / `windowMinutes` fields, so leaving them blank submits a
|
|
28
|
+
* PRESENT-but-empty object (empty strings / `undefined`) rather than omitting
|
|
29
|
+
* `spendCap`. Treat any such empty/incomplete cap as "no cap" so an operator can
|
|
30
|
+
* save a connection without configuring a budget: if either bound is unset or
|
|
31
|
+
* blank, the whole cap collapses to `undefined` (unlimited). A fully-specified
|
|
32
|
+
* cap passes through untouched for the object schema to validate.
|
|
33
|
+
*/
|
|
34
|
+
function normalizeSpendCap(value: unknown): unknown {
|
|
35
|
+
if (value === undefined || value === null || value === "") {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
if (typeof value === "object" && !Array.isArray(value)) {
|
|
39
|
+
const cap = value as Record<string, unknown>;
|
|
40
|
+
const isBlank = (field: unknown): boolean =>
|
|
41
|
+
field === undefined || field === null || field === "";
|
|
42
|
+
if (isBlank(cap.tokenBudget) || isBlank(cap.windowMinutes)) {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const OpenAiCompatibleConnectionSchema = z.object({
|
|
50
|
+
baseUrl: configString({})
|
|
51
|
+
.url()
|
|
52
|
+
.default(OPENAI_COMPATIBLE_DEFAULT_BASE_URL)
|
|
53
|
+
.describe("OpenAI-compatible base URL (e.g. https://api.openai.com/v1)"),
|
|
54
|
+
apiKey: configString({ "x-secret": true }).describe("API key"),
|
|
55
|
+
defaultModel: configString({})
|
|
56
|
+
.min(1)
|
|
57
|
+
.describe("Default model id (e.g. gpt-4o-mini)"),
|
|
58
|
+
availableModels: z
|
|
59
|
+
.array(z.string().min(1))
|
|
60
|
+
.optional()
|
|
61
|
+
.describe("Optional allowlist of selectable model ids"),
|
|
62
|
+
spendCap: z
|
|
63
|
+
.preprocess(
|
|
64
|
+
normalizeSpendCap,
|
|
65
|
+
z
|
|
66
|
+
.object({
|
|
67
|
+
tokenBudget: configNumber({})
|
|
68
|
+
.int()
|
|
69
|
+
.positive()
|
|
70
|
+
.describe(
|
|
71
|
+
"Max total tokens (input + output) per user per window before chat is refused",
|
|
72
|
+
),
|
|
73
|
+
windowMinutes: configNumber({})
|
|
74
|
+
.int()
|
|
75
|
+
.positive()
|
|
76
|
+
.describe(
|
|
77
|
+
"Rolling window length in minutes the token budget resets over",
|
|
78
|
+
),
|
|
79
|
+
})
|
|
80
|
+
.optional(),
|
|
81
|
+
)
|
|
82
|
+
.describe(
|
|
83
|
+
"Optional LLM spend cap (token-count). Leave both fields blank for no cap. Enforced server-side and counted across all pods.",
|
|
84
|
+
),
|
|
85
|
+
}) satisfies z.ZodType<OpenAiCompatibleConnection>;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The OpenAI-compatible integration provider. Registering it through
|
|
89
|
+
* `integrationProviderExtensionPoint` makes it appear in the generic
|
|
90
|
+
* Connections settings UI (rendered from `connectionSchema` via `DynamicForm`),
|
|
91
|
+
* exactly like every other integration provider — no AI-specific Settings page
|
|
92
|
+
* is required for credential management.
|
|
93
|
+
*/
|
|
94
|
+
export function createOpenAiCompatibleProvider(): IntegrationProvider<OpenAiCompatibleConnection> {
|
|
95
|
+
return {
|
|
96
|
+
id: OPENAI_COMPATIBLE_PROVIDER_LOCAL_ID,
|
|
97
|
+
displayName: "OpenAI-compatible",
|
|
98
|
+
description:
|
|
99
|
+
"LLM provider for the AI platform (OpenAI, Azure, OpenRouter, Ollama, vLLM, LM Studio).",
|
|
100
|
+
icon: "Sparkles",
|
|
101
|
+
connectionSchema: new Versioned({
|
|
102
|
+
version: 1,
|
|
103
|
+
schema: OpenAiCompatibleConnectionSchema,
|
|
104
|
+
}),
|
|
105
|
+
documentation: {
|
|
106
|
+
setupGuide: `
|
|
107
|
+
## OpenAI-compatible provider
|
|
108
|
+
|
|
109
|
+
Configure credentials for any OpenAI-compatible chat-completions endpoint.
|
|
110
|
+
|
|
111
|
+
1. Set the base URL (defaults to \`https://api.openai.com/v1\`). For other
|
|
112
|
+
providers use their base URL, e.g. \`https://openrouter.ai/api/v1\` or a
|
|
113
|
+
local \`http://localhost:11434/v1\` for Ollama.
|
|
114
|
+
2. Paste an API key. It is stored in the Secrets Vault and never returned to
|
|
115
|
+
the browser.
|
|
116
|
+
3. Set a default model id (for example \`gpt-4o-mini\`). Optionally add an
|
|
117
|
+
allowlist of selectable models.
|
|
118
|
+
`.trim(),
|
|
119
|
+
},
|
|
120
|
+
async testConnection(
|
|
121
|
+
config: OpenAiCompatibleConnection,
|
|
122
|
+
): Promise<TestConnectionResult> {
|
|
123
|
+
try {
|
|
124
|
+
// Strip trailing slashes without a backtracking-prone regex
|
|
125
|
+
// (`/\/+$/` is O(n^2) on inputs like "////...x" - CodeQL js/polynomial-redos).
|
|
126
|
+
let base = config.baseUrl;
|
|
127
|
+
while (base.endsWith("/")) base = base.slice(0, -1);
|
|
128
|
+
const response = await fetch(`${base}/models`, {
|
|
129
|
+
headers: { authorization: `Bearer ${config.apiKey}` },
|
|
130
|
+
});
|
|
131
|
+
if (!response.ok) {
|
|
132
|
+
return {
|
|
133
|
+
success: false,
|
|
134
|
+
message: `Provider returned ${response.status} ${response.statusText}`,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
return { success: true, message: "Connection successful" };
|
|
138
|
+
} catch (error) {
|
|
139
|
+
return {
|
|
140
|
+
success: false,
|
|
141
|
+
message: `Failed to reach provider: ${extractErrorMessage(error)}`,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { access, definePluginMetadata, proc } from "@checkstack/common";
|
|
4
|
+
import type { AnyContractProcedure } from "@orpc/contract";
|
|
5
|
+
import { buildProjectedTool } from "./projection";
|
|
6
|
+
|
|
7
|
+
const sourcePluginMetadata = definePluginMetadata({ pluginId: "incident" });
|
|
8
|
+
const incidentRead = access("incident", "read", "View incidents");
|
|
9
|
+
|
|
10
|
+
// A realistic contract procedure with access metadata + an input schema.
|
|
11
|
+
const listIncidents = proc({
|
|
12
|
+
operationType: "query",
|
|
13
|
+
userType: "authenticated",
|
|
14
|
+
access: [incidentRead],
|
|
15
|
+
}).input(z.object({ status: z.string().optional() })) as AnyContractProcedure;
|
|
16
|
+
|
|
17
|
+
describe("buildProjectedTool", () => {
|
|
18
|
+
test("requiredAccessRules equals the source procedure's qualified access rules", () => {
|
|
19
|
+
const t = buildProjectedTool({
|
|
20
|
+
procedure: listIncidents,
|
|
21
|
+
sourcePluginMetadata,
|
|
22
|
+
procedureKey: "listIncidents",
|
|
23
|
+
description: "List incidents.",
|
|
24
|
+
effect: "read",
|
|
25
|
+
execute: () => Promise.resolve({}),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// qualifyAccessRuleId: `${pluginId}.${rule.id}` where rule.id = `incident.read`.
|
|
29
|
+
expect(t.requiredAccessRules).toEqual(["incident.incident.read"]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("derives the tool name as <sourcePlugin>.<procedureKey> when no name override", () => {
|
|
33
|
+
const t = buildProjectedTool({
|
|
34
|
+
procedure: listIncidents,
|
|
35
|
+
sourcePluginMetadata,
|
|
36
|
+
procedureKey: "listIncidents",
|
|
37
|
+
description: "List incidents.",
|
|
38
|
+
effect: "read",
|
|
39
|
+
execute: () => Promise.resolve({}),
|
|
40
|
+
});
|
|
41
|
+
expect(t.name).toBe("incident.listIncidents");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("honours a name override", () => {
|
|
45
|
+
const t = buildProjectedTool({
|
|
46
|
+
procedure: listIncidents,
|
|
47
|
+
sourcePluginMetadata,
|
|
48
|
+
procedureKey: "listIncidents",
|
|
49
|
+
name: "incident.list",
|
|
50
|
+
description: "List incidents.",
|
|
51
|
+
effect: "read",
|
|
52
|
+
execute: () => Promise.resolve({}),
|
|
53
|
+
});
|
|
54
|
+
expect(t.name).toBe("incident.list");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("uses the source procedure's input schema", () => {
|
|
58
|
+
const t = buildProjectedTool({
|
|
59
|
+
procedure: listIncidents,
|
|
60
|
+
sourcePluginMetadata,
|
|
61
|
+
procedureKey: "listIncidents",
|
|
62
|
+
description: "List incidents.",
|
|
63
|
+
effect: "read",
|
|
64
|
+
execute: () => Promise.resolve({}),
|
|
65
|
+
});
|
|
66
|
+
// The projected input parses the same shape the procedure declared.
|
|
67
|
+
expect(t.input.safeParse({ status: "open" }).success).toBe(true);
|
|
68
|
+
expect(t.input.safeParse({ status: 123 }).success).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("throws when effect is omitted (never inferred from operationType)", () => {
|
|
72
|
+
expect(() =>
|
|
73
|
+
buildProjectedTool({
|
|
74
|
+
procedure: listIncidents,
|
|
75
|
+
sourcePluginMetadata,
|
|
76
|
+
procedureKey: "listIncidents",
|
|
77
|
+
description: "List incidents.",
|
|
78
|
+
// Force the runtime guard: a JS caller could omit `effect`.
|
|
79
|
+
effect: undefined as unknown as "read",
|
|
80
|
+
execute: () => Promise.resolve({}),
|
|
81
|
+
}),
|
|
82
|
+
).toThrow(/effect.*required/i);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("throws when the value is not an oRPC contract procedure", () => {
|
|
86
|
+
expect(() =>
|
|
87
|
+
buildProjectedTool({
|
|
88
|
+
procedure: {} as AnyContractProcedure,
|
|
89
|
+
sourcePluginMetadata,
|
|
90
|
+
procedureKey: "bogus",
|
|
91
|
+
description: "Bogus.",
|
|
92
|
+
effect: "read",
|
|
93
|
+
execute: () => Promise.resolve({}),
|
|
94
|
+
}),
|
|
95
|
+
).toThrow(/not an oRPC contract procedure/i);
|
|
96
|
+
});
|
|
97
|
+
});
|