@tonyclaw/llm-inspector 1.12.0 → 1.14.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.
Files changed (35) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/assets/index-B5q3Llgm.css +1 -0
  3. package/.output/public/assets/index-C6tbslcs.js +105 -0
  4. package/.output/public/assets/{main-BYCM7aJx.js → main-C1k6vRnH.js} +3 -3
  5. package/.output/server/_libs/cfworker__json-schema.mjs +1 -0
  6. package/.output/server/_libs/lucide-react.mjs +64 -58
  7. package/.output/server/_libs/modelcontextprotocol__server.mjs +9738 -0
  8. package/.output/server/_libs/zod.mjs +79 -16
  9. package/.output/server/_ssr/{index-DhChP_jV.mjs → index-AxruZp16.mjs} +1201 -165
  10. package/.output/server/_ssr/index.mjs +2 -2
  11. package/.output/server/_ssr/{router-PZjNwOcw.mjs → router-DtleGqN8.mjs} +650 -29
  12. package/.output/server/_tanstack-start-manifest_v-B1WAHWIa.mjs +4 -0
  13. package/.output/server/index.mjs +24 -24
  14. package/README.md +98 -0
  15. package/package.json +3 -1
  16. package/src/components/ProxyViewer.tsx +126 -2
  17. package/src/components/proxy-viewer/CompareDrawer.tsx +880 -0
  18. package/src/components/proxy-viewer/ConversationGroup.tsx +8 -0
  19. package/src/components/proxy-viewer/LogEntry.tsx +14 -1
  20. package/src/components/proxy-viewer/LogEntryHeader.tsx +28 -0
  21. package/src/components/proxy-viewer/formats/openai/ResponseView.tsx +74 -4
  22. package/src/components/proxy-viewer/requestDiff.ts +277 -0
  23. package/src/lib/serverPort.ts +41 -0
  24. package/src/mcp/loopback.ts +76 -0
  25. package/src/mcp/previewExtractor.ts +166 -0
  26. package/src/mcp/server.ts +320 -0
  27. package/src/mcp/toolHandlers.ts +259 -0
  28. package/src/proxy/formats/openai/schemas.ts +19 -0
  29. package/src/proxy/handler.ts +23 -2
  30. package/src/proxy/openaiOrphanToolStrip.ts +148 -0
  31. package/src/proxy/schemas.ts +1 -0
  32. package/src/routes/api/mcp.ts +25 -0
  33. package/.output/public/assets/index-DVgdkDgq.js +0 -105
  34. package/.output/public/assets/index-DZx2yk8v.css +0 -1
  35. package/.output/server/_tanstack-start-manifest_v-l1kWkG0h.mjs +0 -4
@@ -0,0 +1,259 @@
1
+ /**
2
+ * Pure (dependency-injected) tool handler implementations for the MCP
3
+ * server. These are split out from `server.ts` so unit tests can pass a
4
+ * mock `callApi` without spinning up a real HTTP loopback. The
5
+ * `registerTool` calls in `server.ts` simply wrap each impl in `safeCall`
6
+ * and bind the real `callApi` from `./loopback`.
7
+ *
8
+ * Each impl returns a `CallToolResult`-shaped object: either
9
+ * `{ content: [{ type: "text", text: string }] }` on success or
10
+ * `{ content: [...], isError: true }` on failure. The wrapping
11
+ * `safeCall` in `server.ts` only catches *thrown* errors (loopback
12
+ * timeouts, etc); semantic errors (404 from /api) are returned as
13
+ * `isError: true` results from inside the impl.
14
+ */
15
+
16
+ import { z } from "zod";
17
+ import { LoopbackTimeoutError, type CallApiOptions } from "./loopback";
18
+ import { extractLastUserMessagePreview, extractResponsePreview } from "./previewExtractor";
19
+ import { CapturedLogSchema, type CapturedLog } from "../proxy/schemas";
20
+
21
+ /** Minimal callApi contract — same shape as the real `callApi` in loopback.ts. */
22
+ export type CallApiFn = (path: string, options?: CallApiOptions) => Promise<Response>;
23
+
24
+ export type ToolResult = {
25
+ content: { type: "text"; text: string }[];
26
+ isError?: true;
27
+ };
28
+
29
+ // Loose shape returned by `GET /api/logs` — `logs: CapturedLog[]` and a few
30
+ // pagination fields. The CapturedLog items are fully validated downstream.
31
+ const LogsListResponseSchema = z.object({
32
+ logs: z.array(CapturedLogSchema).optional(),
33
+ total: z.number().optional(),
34
+ offset: z.number().optional(),
35
+ limit: z.number().optional(),
36
+ });
37
+
38
+ const PAGINATION_DEFAULTS = { offset: 0, limit: 3 };
39
+ export const LIMIT_HARD_CAP = 5;
40
+
41
+ export function clampLimit(input: number | undefined): number {
42
+ if (input === undefined) return PAGINATION_DEFAULTS.limit;
43
+ if (!Number.isFinite(input) || input < 1) return 1;
44
+ return Math.min(Math.floor(input), LIMIT_HARD_CAP);
45
+ }
46
+
47
+ function textJson(data: unknown): ToolResult {
48
+ return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
49
+ }
50
+
51
+ function toolError(message: string): ToolResult {
52
+ return { content: [{ type: "text", text: message }], isError: true };
53
+ }
54
+
55
+ export function buildLogSummary(log: CapturedLog) {
56
+ const hasError =
57
+ (log.error !== null && log.error !== undefined && log.error.length > 0) ||
58
+ (log.responseStatus !== null && log.responseStatus !== undefined && log.responseStatus >= 400);
59
+ return {
60
+ id: log.id,
61
+ timestamp: log.timestamp,
62
+ provider: log.providerName ?? null,
63
+ model: log.model,
64
+ apiFormat: log.apiFormat,
65
+ method: log.method,
66
+ path: log.path,
67
+ status: log.responseStatus,
68
+ isStreaming: log.streaming,
69
+ hasError,
70
+ latencyMs: log.elapsedMs,
71
+ tokens: {
72
+ input: log.inputTokens,
73
+ output: log.outputTokens,
74
+ cacheCreate: log.cacheCreationInputTokens,
75
+ cacheRead: log.cacheReadInputTokens,
76
+ },
77
+ sessionId: log.sessionId,
78
+ clientPid: log.clientPid ?? null,
79
+ lastUserMessagePreview: extractLastUserMessagePreview(log),
80
+ responsePreview: extractResponsePreview(log),
81
+ hasChunks:
82
+ (log.streamingChunks !== undefined && log.streamingChunks !== null) ||
83
+ (log.streamingChunksPath !== null && log.streamingChunksPath !== undefined),
84
+ hasRawRequestBody: log.rawRequestBody !== null && log.rawRequestBody !== undefined,
85
+ };
86
+ }
87
+
88
+ // ===========================================================================
89
+ // Per-tool impls
90
+ // ===========================================================================
91
+
92
+ export type ListLogsArgs = {
93
+ offset?: number;
94
+ limit?: number;
95
+ sessionId?: string;
96
+ model?: string;
97
+ };
98
+
99
+ export async function listLogsImpl(callApi: CallApiFn, args: ListLogsArgs): Promise<ToolResult> {
100
+ const offset = args.offset ?? PAGINATION_DEFAULTS.offset;
101
+ const limit = clampLimit(args.limit);
102
+ const params = new URLSearchParams({ offset: String(offset), limit: String(limit) });
103
+ if (args.sessionId !== undefined && args.sessionId.length > 0) {
104
+ params.set("sessionId", args.sessionId);
105
+ }
106
+ if (args.model !== undefined && args.model.length > 0) {
107
+ params.set("model", args.model);
108
+ }
109
+ const res = await callApi(`/api/logs?${params.toString()}`);
110
+ if (!res.ok) return toolError(`GET /api/logs returned ${res.status}`);
111
+ const rawBody: unknown = await res.json();
112
+ const parsed = LogsListResponseSchema.safeParse(rawBody);
113
+ if (!parsed.success) return toolError("GET /api/logs returned an unparseable shape");
114
+ const logs = parsed.data.logs ?? [];
115
+ return textJson(logs.map(buildLogSummary));
116
+ }
117
+
118
+ export async function getLogImpl(callApi: CallApiFn, id: number): Promise<ToolResult> {
119
+ const res = await callApi(`/api/logs/${id}`);
120
+ if (!res.ok) return toolError(`GET /api/logs/${id} returned ${res.status}`);
121
+ const rawBody: unknown = await res.json();
122
+ const parsed = CapturedLogSchema.safeParse(rawBody);
123
+ if (!parsed.success) {
124
+ return toolError(`GET /api/logs/${id} returned an unparseable CapturedLog`);
125
+ }
126
+ // Strip streamingChunks per design D6 — the agent asks for chunks
127
+ // explicitly via inspector_get_log_chunks.
128
+ const { streamingChunks: _chunks, ...rest } = parsed.data;
129
+ return textJson(rest);
130
+ }
131
+
132
+ export async function getLogChunksImpl(callApi: CallApiFn, id: number): Promise<ToolResult> {
133
+ const res = await callApi(`/api/logs/${id}/chunks`);
134
+ if (!res.ok) return toolError(`GET /api/logs/${id}/chunks returned ${res.status}`);
135
+ return textJson(await res.json());
136
+ }
137
+
138
+ export async function listSessionsImpl(callApi: CallApiFn): Promise<ToolResult> {
139
+ const res = await callApi("/api/sessions");
140
+ if (!res.ok) return toolError(`GET /api/sessions returned ${res.status}`);
141
+ return textJson(await res.json());
142
+ }
143
+
144
+ export async function listModelsImpl(callApi: CallApiFn): Promise<ToolResult> {
145
+ const res = await callApi("/api/models");
146
+ if (!res.ok) return toolError(`GET /api/models returned ${res.status}`);
147
+ return textJson(await res.json());
148
+ }
149
+
150
+ export async function listProvidersImpl(callApi: CallApiFn): Promise<ToolResult> {
151
+ const res = await callApi("/api/providers");
152
+ if (!res.ok) return toolError(`GET /api/providers returned ${res.status}`);
153
+ return textJson(await res.json());
154
+ }
155
+
156
+ export async function getProviderImpl(callApi: CallApiFn, id: string): Promise<ToolResult> {
157
+ const res = await callApi(`/api/providers/${encodeURIComponent(id)}`);
158
+ if (!res.ok) return toolError(`GET /api/providers/${id} returned ${res.status}`);
159
+ return textJson(await res.json());
160
+ }
161
+
162
+ export type ReplayLogArgs = { id: number };
163
+
164
+ export async function replayLogImpl(callApi: CallApiFn, args: ReplayLogArgs): Promise<ToolResult> {
165
+ const logRes = await callApi(`/api/logs/${args.id}`);
166
+ if (!logRes.ok) return toolError(`GET /api/logs/${args.id} returned ${logRes.status}`);
167
+ const logRaw: unknown = await logRes.json();
168
+ const logParsed = CapturedLogSchema.safeParse(logRaw);
169
+ if (!logParsed.success) {
170
+ return toolError(`GET /api/logs/${args.id} returned an unparseable CapturedLog`);
171
+ }
172
+ const log = logParsed.data;
173
+ if (log.rawRequestBody === null) return toolError("Log has no rawRequestBody to replay");
174
+ const replayRes = await callApi(`/api/logs/${args.id}/replay`, {
175
+ method: "POST",
176
+ headers: { "content-type": "application/json" },
177
+ body: JSON.stringify({ modifiedBody: log.rawRequestBody }),
178
+ });
179
+ if (!replayRes.ok) {
180
+ return toolError(`POST /api/logs/${args.id}/replay returned ${replayRes.status}`);
181
+ }
182
+ return textJson(await replayRes.json());
183
+ }
184
+
185
+ export type AddProviderInput = {
186
+ name: string;
187
+ apiKey: string;
188
+ format: "anthropic" | "openai";
189
+ model: string;
190
+ anthropicBaseUrl?: string;
191
+ openaiBaseUrl?: string;
192
+ authHeader?: "bearer" | "x-api-key";
193
+ apiDocsUrl?: string;
194
+ };
195
+
196
+ export async function addProviderImpl(
197
+ callApi: CallApiFn,
198
+ provider: AddProviderInput,
199
+ ): Promise<ToolResult> {
200
+ const res = await callApi("/api/providers", {
201
+ method: "POST",
202
+ headers: { "content-type": "application/json" },
203
+ body: JSON.stringify(provider),
204
+ });
205
+ if (!res.ok) return toolError(`POST /api/providers returned ${res.status}`);
206
+ return textJson(await res.json());
207
+ }
208
+
209
+ export type UpdateProviderInput = {
210
+ id: string;
211
+ name?: string;
212
+ apiKey?: string;
213
+ format?: "anthropic" | "openai";
214
+ model?: string;
215
+ baseUrl?: string;
216
+ authHeader?: "bearer" | "x-api-key";
217
+ anthropicBaseUrl?: string;
218
+ openaiBaseUrl?: string;
219
+ apiDocsUrl?: string;
220
+ };
221
+
222
+ export async function updateProviderImpl(
223
+ callApi: CallApiFn,
224
+ input: UpdateProviderInput,
225
+ ): Promise<ToolResult> {
226
+ const { id, ...patch } = input;
227
+ const res = await callApi(`/api/providers/${encodeURIComponent(id)}`, {
228
+ method: "PUT",
229
+ headers: { "content-type": "application/json" },
230
+ body: JSON.stringify(patch),
231
+ });
232
+ if (!res.ok) return toolError(`PUT /api/providers/${id} returned ${res.status}`);
233
+ return textJson(await res.json());
234
+ }
235
+
236
+ export async function testProviderImpl(callApi: CallApiFn, id: string): Promise<ToolResult> {
237
+ const res = await callApi(`/api/providers/${encodeURIComponent(id)}/test`, {
238
+ method: "POST",
239
+ });
240
+ if (!res.ok) return toolError(`POST /api/providers/${id}/test returned ${res.status}`);
241
+ return textJson(await res.json());
242
+ }
243
+
244
+ /**
245
+ * Wrap an impl so any thrown error becomes a well-formed `isError: true`
246
+ * CallToolResult (instead of a JSON-RPC protocol error, which would abort
247
+ * the whole session). Loopback timeouts get a clear message.
248
+ */
249
+ export async function safeCall(fn: () => Promise<ToolResult>): Promise<ToolResult> {
250
+ try {
251
+ return await fn();
252
+ } catch (err) {
253
+ if (err instanceof LoopbackTimeoutError) {
254
+ return toolError(`Internal loopback call timed out after ${err.timeoutMs}ms: ${err.path}`);
255
+ }
256
+ const message = err instanceof Error ? err.message : String(err);
257
+ return toolError(`Tool execution failed: ${message}`);
258
+ }
259
+ }
@@ -30,11 +30,29 @@ export const OpenAIFunctionCall = z.object({
30
30
  arguments: z.string(),
31
31
  });
32
32
 
33
+ /** A single OpenAI tool_call (the modern multi-tool form, returned by most
34
+ * providers in `choices[].message.tool_calls`). `function.arguments` is
35
+ * a JSON-encoded string per the OpenAI spec — we keep it as a string at
36
+ * parse time and let the renderer decide whether to JSON-parse it for
37
+ * display. */
38
+ export const OpenAIToolCallSchema = z.object({
39
+ index: z.number().optional(),
40
+ id: z.string().optional(),
41
+ type: z.literal("function").optional(),
42
+ function: z.object({
43
+ name: z.string().optional(),
44
+ arguments: z.string().optional(),
45
+ }),
46
+ });
47
+
48
+ export type OpenAIToolCall = z.infer<typeof OpenAIToolCallSchema>;
49
+
33
50
  const OpenAIMessageWithFunctionCall = OpenAIMessage.extend({
34
51
  content: z
35
52
  .union([z.string(), z.array(z.object({ type: z.literal("text"), text: z.string() }))])
36
53
  .optional(),
37
54
  function_call: OpenAIFunctionCall.optional(),
55
+ tool_calls: z.array(OpenAIToolCallSchema).optional(),
38
56
  });
39
57
 
40
58
  export const OpenAIToolDefinition = z.object({
@@ -99,6 +117,7 @@ export const OpenAIChoice = z.object({
99
117
  thinking: z.string().optional(), // Some providers use 'thinking' field in message
100
118
  think: z.string().optional(), // MiniMax uses 'think' field in message
101
119
  function_call: z.object({ name: z.string(), arguments: z.string() }).nullable().optional(),
120
+ tool_calls: z.array(OpenAIToolCallSchema).optional(),
102
121
  })
103
122
  .optional(),
104
123
  delta: OpenAIChoiceDelta.optional(),
@@ -21,6 +21,7 @@ import {
21
21
  } from "./constants";
22
22
  import { getConfig } from "./config";
23
23
  import { stripClaudeCodeBillingHeader } from "./claudeCodeStrip";
24
+ import { stripOpenAIOrphanToolMessages } from "./openaiOrphanToolStrip";
24
25
  import {
25
26
  buildUpstreamUrl,
26
27
  describeApiRoute,
@@ -89,17 +90,20 @@ function buildFileLogEntry(log: CapturedLog, upstreamUrl: string): Record<string
89
90
  type ParsedRequestPath = {
90
91
  apiPath: string;
91
92
  isMessages: boolean;
93
+ isChatCompletions: boolean;
92
94
  normalizedPath: string;
93
95
  };
94
96
 
95
97
  function parseRequestPath(req: Request, url: URL): ParsedRequestPath {
96
98
  const route = describeApiRoute(getProxyApiPath(url));
97
- const isMessages =
98
- req.method === "POST" && (route.endpointPath === PATH_V1_MESSAGES || route.isChatCompletions);
99
+ const isPost = req.method === "POST";
100
+ const isChatCompletions = isPost && route.isChatCompletions;
101
+ const isMessages = isPost && (route.endpointPath === PATH_V1_MESSAGES || route.isChatCompletions);
99
102
 
100
103
  return {
101
104
  apiPath: route.apiPath,
102
105
  isMessages,
106
+ isChatCompletions,
103
107
  normalizedPath: route.normalizedPath,
104
108
  };
105
109
  }
@@ -242,6 +246,23 @@ export async function handleProxy(req: Request): Promise<Response> {
242
246
  }
243
247
  }
244
248
 
249
+ // Always-on protocol guard: OpenAI Chat Completions requires every
250
+ // `role: "tool"` message to reference a `tool_call_id` present in a
251
+ // preceding assistant message's `tool_calls`. Buggy clients sometimes
252
+ // record a tool result without the matching assistant tool_call, which
253
+ // makes the upstream reject the request with a 400 ("tool result's tool
254
+ // id(call_xxx) not found"). Drop the orphan messages so the proxy can
255
+ // forward a valid request; the original body is still kept in the log.
256
+ if (bodyToForward !== null && parsed.isChatCompletions) {
257
+ const stripped = stripOpenAIOrphanToolMessages(bodyToForward);
258
+ if (stripped.removed > 0) {
259
+ logger.warn(
260
+ `[handler] Dropped ${stripped.removed} orphan OpenAI tool message(s) with tool_call_id(s) ${JSON.stringify(stripped.orphanIds)} — the client sent a tool result with no matching assistant.tool_calls`,
261
+ );
262
+ bodyToForward = stripped.body;
263
+ }
264
+ }
265
+
245
266
  // Parse the request body exactly once. The handler needs the model for
246
267
  // provider routing and the session id for the log; `createLog` would
247
268
  // otherwise re-parse the same body to extract the same fields.
@@ -0,0 +1,148 @@
1
+ /**
2
+ * OpenAI Chat Completions protocol guard: orphan `tool` messages.
3
+ *
4
+ * The OpenAI Chat Completions spec requires every message with
5
+ * `role: "tool"` to reference a `tool_call_id` that appears in a preceding
6
+ * `assistant` message's `tool_calls` array. When this invariant is broken
7
+ * (typically by a buggy client that records a tool result without the
8
+ * matching assistant tool_call), the upstream rejects the request with a
9
+ * 400 like:
10
+ *
11
+ * {"type":"error","error":{"type":"bad_request_error",
12
+ * "message":"invalid params, tool result's tool id(call_xxx) not found"}}
13
+ *
14
+ * This module finds such orphan tool messages and returns a rewritten body
15
+ * with them removed so the proxy can forward a valid request. The original
16
+ * body is preserved by the caller for the captured log, so the inspector UI
17
+ * still shows the broken request the client actually sent.
18
+ */
19
+
20
+ const ROLE_ASSISTANT = "assistant";
21
+ const ROLE_TOOL = "tool";
22
+
23
+ export type OrphanToolStripResult = {
24
+ /** The new request body. Equal to input (by reference) when no change was made. */
25
+ body: string;
26
+ /** Number of orphan tool messages removed. */
27
+ removed: number;
28
+ /**
29
+ * The `tool_call_id` values of the removed messages, in document order.
30
+ * `null` is used when a tool message had no `tool_call_id` at all.
31
+ */
32
+ orphanIds: Array<string | null>;
33
+ };
34
+
35
+ function getOwnProperty(obj: object, key: string): unknown {
36
+ const desc = Object.getOwnPropertyDescriptor(obj, key);
37
+ return desc?.value;
38
+ }
39
+
40
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
41
+ return typeof value === "object" && value !== null && !Array.isArray(value);
42
+ }
43
+
44
+ function isObjectWithMessages(value: unknown): value is { messages: unknown[] } {
45
+ if (!isPlainObject(value)) return false;
46
+ if (!Object.prototype.hasOwnProperty.call(value, "messages")) return false;
47
+ const messages = getOwnProperty(value, "messages");
48
+ return Array.isArray(messages);
49
+ }
50
+
51
+ /**
52
+ * Collects the set of tool_call ids from an assistant message's `tool_calls`
53
+ * array. Tolerant of malformed entries: anything without a string `id` is
54
+ * skipped silently (those ids cannot be referenced by a tool message anyway).
55
+ */
56
+ function collectAssistantToolCallIds(message: object, into: Set<string>): void {
57
+ const toolCalls = getOwnProperty(message, "tool_calls");
58
+ if (!Array.isArray(toolCalls)) return;
59
+ for (const tc of toolCalls) {
60
+ if (!isPlainObject(tc)) continue;
61
+ const id = getOwnProperty(tc, "id");
62
+ if (typeof id === "string" && id.length > 0) {
63
+ into.add(id);
64
+ }
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Returns the indices of `tool`-role messages whose `tool_call_id` does not
70
+ * appear in any *preceding* assistant message's `tool_calls`. A tool message
71
+ * with no `tool_call_id` at all is also considered orphan.
72
+ *
73
+ * Pure, exported for unit tests. Used by `stripOpenAIOrphanToolMessages`.
74
+ */
75
+ export function findOrphanToolMessageIndices(messages: readonly unknown[]): {
76
+ indices: number[];
77
+ orphanIds: Array<string | null>;
78
+ } {
79
+ const knownToolCallIds = new Set<string>();
80
+ const indices: number[] = [];
81
+ const orphanIds: Array<string | null> = [];
82
+
83
+ for (let i = 0; i < messages.length; i++) {
84
+ const msg = messages[i];
85
+ if (!isPlainObject(msg)) continue;
86
+
87
+ const role = getOwnProperty(msg, "role");
88
+
89
+ if (role === ROLE_ASSISTANT) {
90
+ collectAssistantToolCallIds(msg, knownToolCallIds);
91
+ continue;
92
+ }
93
+
94
+ if (role === ROLE_TOOL) {
95
+ const id = getOwnProperty(msg, "tool_call_id");
96
+ if (typeof id !== "string" || id.length === 0) {
97
+ indices.push(i);
98
+ orphanIds.push(null);
99
+ continue;
100
+ }
101
+ if (!knownToolCallIds.has(id)) {
102
+ indices.push(i);
103
+ orphanIds.push(id);
104
+ }
105
+ }
106
+ }
107
+
108
+ return { indices, orphanIds };
109
+ }
110
+
111
+ /**
112
+ * Inspects an OpenAI-format request body and, if its `messages` array
113
+ * contains one or more orphan `tool` messages (see `findOrphanToolMessageIndices`),
114
+ * returns a new body with those messages removed. The original string is
115
+ * returned unchanged (by reference equality) when no edit is needed so the
116
+ * caller can skip a re-serialization.
117
+ *
118
+ * Tolerant of bodies that are not valid JSON, not OpenAI-format, or that
119
+ * have no `messages` array — all such inputs are returned unchanged.
120
+ */
121
+ export function stripOpenAIOrphanToolMessages(rawBody: string): OrphanToolStripResult {
122
+ let parsed: unknown;
123
+ try {
124
+ parsed = JSON.parse(rawBody);
125
+ } catch {
126
+ return { body: rawBody, removed: 0, orphanIds: [] };
127
+ }
128
+
129
+ if (!isObjectWithMessages(parsed)) {
130
+ return { body: rawBody, removed: 0, orphanIds: [] };
131
+ }
132
+
133
+ const messages = parsed.messages;
134
+ const { indices, orphanIds } = findOrphanToolMessageIndices(messages);
135
+
136
+ if (indices.length === 0) {
137
+ return { body: rawBody, removed: 0, orphanIds: [] };
138
+ }
139
+
140
+ const dropSet = new Set(indices);
141
+ const kept: unknown[] = [];
142
+ for (let i = 0; i < messages.length; i++) {
143
+ if (!dropSet.has(i)) kept.push(messages[i]);
144
+ }
145
+ parsed.messages = kept;
146
+
147
+ return { body: JSON.stringify(parsed), removed: indices.length, orphanIds };
148
+ }
@@ -98,6 +98,7 @@ export {
98
98
  OpenAIResponseSchema,
99
99
  OpenAISSERawChunkSchema,
100
100
  type OpenAIResponse,
101
+ type OpenAIToolCall,
101
102
  parseOpenAIResponse,
102
103
  } from "./formats/openai/schemas";
103
104
 
@@ -0,0 +1,25 @@
1
+ import { createFileRoute } from "@tanstack/react-router";
2
+ import { handleMcpRequest } from "../../mcp/server";
3
+
4
+ /**
5
+ * MCP HTTP Streamable transport endpoint. Mounted at `POST /api/mcp` on the
6
+ * existing TanStack Start HTTP server — see
7
+ * `openspec/changes/add-mcp-server/design.md` D1.
8
+ *
9
+ * All method/header routing is handled inside the SDK's
10
+ * `WebStandardStreamableHTTPServerTransport` (it dispatches GET for SSE
11
+ * stream, POST for client→server requests, DELETE for session close), so
12
+ * the route simply forwards the request.
13
+ */
14
+ export const Route = createFileRoute("/api/mcp")({
15
+ server: {
16
+ handlers: {
17
+ // The SDK may issue either POST, GET, or DELETE. TanStack Start only
18
+ // requires us to declare the methods we accept; routing by method is
19
+ // handled inside the transport.
20
+ POST: ({ request }: { request: Request }) => handleMcpRequest(request),
21
+ GET: ({ request }: { request: Request }) => handleMcpRequest(request),
22
+ DELETE: ({ request }: { request: Request }) => handleMcpRequest(request),
23
+ },
24
+ },
25
+ });