sift-light 1.0.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 +0 -0
- package/CONTRIBUTING.md +19 -0
- package/LICENSE +661 -0
- package/README.md +177 -0
- package/README.zh-CN.md +177 -0
- package/SECURITY.md +25 -0
- package/package.json +142 -0
- package/plugins/sift-light/.claude-plugin/plugin.json +10 -0
- package/plugins/sift-light/.codex-plugin/plugin.json +20 -0
- package/plugins/sift-light/.mcp.json +24 -0
- package/plugins/sift-light/LICENSE +661 -0
- package/plugins/sift-light/concept-worker.mjs +567 -0
- package/plugins/sift-light/hooks/THIRD_PARTY_NOTICES.txt +70 -0
- package/plugins/sift-light/hooks/hooks.json +16 -0
- package/plugins/sift-light/hooks/search-policy.mjs +4113 -0
- package/plugins/sift-light/hooks/tree-sitter-bash.wasm +0 -0
- package/plugins/sift-light/hooks/tree-sitter-powershell.wasm +0 -0
- package/plugins/sift-light/hooks/tree-sitter.wasm +0 -0
- package/plugins/sift-light/kimi.plugin.json +36 -0
- package/plugins/sift-light/mcp-server.mjs +1 -0
- package/plugins/sift-light/omp-extension.mjs +20914 -0
- package/plugins/sift-light/package.json +11 -0
- package/plugins/sift-light/syntax-worker.mjs +280 -0
- package/plugins/sift-light/syntax-worker.toml +2 -0
- package/src/analysis-evidence.ts +43 -0
- package/src/analysis-limits.ts +27 -0
- package/src/analysis-store.ts +559 -0
- package/src/analysis-term-pages.ts +73 -0
- package/src/analysis-types.ts +162 -0
- package/src/audit-search.ts +391 -0
- package/src/capped-lines.ts +112 -0
- package/src/concept-embedding-cache.ts +186 -0
- package/src/concept-inference.ts +218 -0
- package/src/concept-model.ts +96 -0
- package/src/concept-search.ts +577 -0
- package/src/concept-setup.ts +75 -0
- package/src/concept-source-generation.ts +268 -0
- package/src/concept-worker.mjs +567 -0
- package/src/concept-worker.ts +210 -0
- package/src/config-reader.ts +283 -0
- package/src/config.ts +37 -0
- package/src/context-budget.ts +26 -0
- package/src/discovery-errors.ts +2 -0
- package/src/errors.ts +95 -0
- package/src/evidence-candidates.ts +344 -0
- package/src/evidence-ranking.ts +48 -0
- package/src/evidence-service.ts +1047 -0
- package/src/evidence-validation.ts +466 -0
- package/src/evidence-validity.ts +33 -0
- package/src/excerpt.ts +53 -0
- package/src/file-discovery.ts +150 -0
- package/src/file-metadata-filter.ts +46 -0
- package/src/format.ts +407 -0
- package/src/git-diff.ts +195 -0
- package/src/git-process.ts +189 -0
- package/src/git-repository.ts +367 -0
- package/src/git-source.ts +385 -0
- package/src/historical-paths.ts +205 -0
- package/src/hybrid-search.ts +340 -0
- package/src/import-model.ts +611 -0
- package/src/import-navigation.ts +100 -0
- package/src/import-resolution.ts +240 -0
- package/src/index.ts +97 -0
- package/src/inspect-batch.ts +265 -0
- package/src/inspect.ts +271 -0
- package/src/language-capabilities.ts +157 -0
- package/src/language-capability-definitions.ts +204 -0
- package/src/literal-search.ts +25 -0
- package/src/mcp-cli.ts +21 -0
- package/src/mcp-model-output.ts +120 -0
- package/src/mcp-output.ts +9 -0
- package/src/mcp-semantic-judge.ts +39 -0
- package/src/mcp-server.mjs +14064 -0
- package/src/mcp-server.ts +157 -0
- package/src/mcp-stdio.ts +114 -0
- package/src/mcp.ts +631 -0
- package/src/model-error.ts +120 -0
- package/src/multi-term-search.ts +122 -0
- package/src/omp-index.ts +277 -0
- package/src/omp-marketplace-entry.ts +7 -0
- package/src/operation-lifecycle.ts +413 -0
- package/src/operation-output.ts +100 -0
- package/src/owned-parallel.ts +22 -0
- package/src/owned-process.ts +102 -0
- package/src/owned-task-queue.ts +38 -0
- package/src/package-version.ts +3 -0
- package/src/path-policy.ts +203 -0
- package/src/pi-search-policy.ts +33 -0
- package/src/prompt-guidelines.ts +54 -0
- package/src/python-outline.ts +231 -0
- package/src/record-value.ts +3 -0
- package/src/redaction.ts +95 -0
- package/src/request-contract-catalog.ts +215 -0
- package/src/request-contract-error.ts +96 -0
- package/src/request-contract.ts +481 -0
- package/src/request.ts +132 -0
- package/src/result-statistics.ts +178 -0
- package/src/rg.ts +574 -0
- package/src/ripgrep-diagnostics.ts +122 -0
- package/src/ripgrep-executable.ts +43 -0
- package/src/runtime.ts +53 -0
- package/src/scan-revisions.ts +148 -0
- package/src/script-runtime.ts +11 -0
- package/src/search-policy-commands.ts +170 -0
- package/src/search-policy-hook.ts +61 -0
- package/src/search-policy-recovery.ts +388 -0
- package/src/search-policy-shell.ts +280 -0
- package/src/search-policy.ts +102 -0
- package/src/search-retention.ts +77 -0
- package/src/semantic-judge-batches.ts +135 -0
- package/src/semantic-judge.ts +519 -0
- package/src/service.ts +945 -0
- package/src/session-summary.ts +100 -0
- package/src/snapshot-store.ts +203 -0
- package/src/source-access.ts +288 -0
- package/src/source-continuations.ts +168 -0
- package/src/source-document.ts +277 -0
- package/src/source-inspection.ts +731 -0
- package/src/source-pages.ts +112 -0
- package/src/source.ts +286 -0
- package/src/structural-search.ts +125 -0
- package/src/structure.ts +259 -0
- package/src/summary-previews.ts +96 -0
- package/src/summary.ts +79 -0
- package/src/syntax-facts.ts +464 -0
- package/src/syntax-fields.ts +161 -0
- package/src/syntax-search.ts +361 -0
- package/src/syntax-tree.ts +30 -0
- package/src/syntax-types.ts +72 -0
- package/src/syntax-worker.mjs +280 -0
- package/src/syntax-worker.toml +2 -0
- package/src/syntax-worker.ts +143 -0
- package/src/syntax.ts +259 -0
- package/src/test-navigation-facts.ts +261 -0
- package/src/test-navigation.ts +543 -0
- package/src/tool-schema.ts +321 -0
- package/src/tui/dashboard.ts +107 -0
- package/src/tui/layout.ts +291 -0
- package/src/tui/palette.ts +73 -0
- package/src/tui/presentation.ts +240 -0
- package/src/tui/renderers.ts +106 -0
- package/src/types.ts +368 -0
- package/src/validation-output.ts +148 -0
- package/src/validation-types.ts +48 -0
- package/src/workspace-files.ts +142 -0
package/src/mcp.ts
ADDED
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
3
|
+
import { URL } from "node:url";
|
|
4
|
+
import { SIFT_LIGHT_VERSION } from "./package-version.js";
|
|
5
|
+
import { Value } from "typebox/value";
|
|
6
|
+
import {
|
|
7
|
+
CallToolRequestSchema,
|
|
8
|
+
isInitializeRequest,
|
|
9
|
+
ListToolsRequestSchema,
|
|
10
|
+
type Tool,
|
|
11
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
12
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
13
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
14
|
+
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
|
15
|
+
import type { SiftLightResult } from "./types.js";
|
|
16
|
+
import {
|
|
17
|
+
DEFAULT_MCP_OUTPUT_MODE,
|
|
18
|
+
parseSiftLightMcpOutputMode,
|
|
19
|
+
type SiftLightMcpOutputMode,
|
|
20
|
+
} from "./mcp-output.js";
|
|
21
|
+
import { compactMcpModelText } from "./mcp-model-output.js";
|
|
22
|
+
import { modelErrorText, requestContractProjection } from "./model-error.js";
|
|
23
|
+
import {
|
|
24
|
+
isSiftLightDiagnosticError,
|
|
25
|
+
RequestContractError,
|
|
26
|
+
schemaContractError,
|
|
27
|
+
} from "./request-contract.js";
|
|
28
|
+
import { createRipgrepRunner } from "./rg.js";
|
|
29
|
+
import { createCtagsStructureProvider } from "./structure.js";
|
|
30
|
+
import { SiftLightService, type SiftLightInput, type SiftLightSearchOptions } from "./service.js";
|
|
31
|
+
import {
|
|
32
|
+
createDisabledSemanticJudgeIntegration,
|
|
33
|
+
type SemanticJudgeIntegration,
|
|
34
|
+
} from "./semantic-judge.js";
|
|
35
|
+
import { DEFAULT_SEMANTIC_JUDGE_CONFIG } from "./config-reader.js";
|
|
36
|
+
import { siftLightMcpInstructions } from "./prompt-guidelines.js";
|
|
37
|
+
import {
|
|
38
|
+
SIFT_LIGHT_DESCRIPTION,
|
|
39
|
+
SIFT_LIGHT_MODEL_DESCRIPTION,
|
|
40
|
+
siftLightSchema,
|
|
41
|
+
} from "./tool-schema.js";
|
|
42
|
+
|
|
43
|
+
export const SIFT_LIGHT_MCP_PATH = "/mcp";
|
|
44
|
+
export const DEFAULT_MCP_HOST = "127.0.0.1";
|
|
45
|
+
export const DEFAULT_MCP_PORT = 3000;
|
|
46
|
+
export const DEFAULT_MCP_MAX_SESSIONS = 100;
|
|
47
|
+
export const DEFAULT_MCP_SESSION_IDLE_TIMEOUT_MS = 10 * 60 * 1000;
|
|
48
|
+
export const MAX_MCP_BODY_BYTES = 16 * 1024 * 1024;
|
|
49
|
+
|
|
50
|
+
const SIFT_LIGHT_OUTPUT_SCHEMA: Tool["outputSchema"] = {
|
|
51
|
+
type: "object",
|
|
52
|
+
properties: {
|
|
53
|
+
text: {
|
|
54
|
+
type: "string",
|
|
55
|
+
description:
|
|
56
|
+
"Complete formatted result page, including statistics, coverage and continuation requests; may contain bounded source excerpts or inspection windows.",
|
|
57
|
+
},
|
|
58
|
+
details: { type: "object" },
|
|
59
|
+
},
|
|
60
|
+
required: ["text", "details"],
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
function siftLightTool(outputMode: SiftLightMcpOutputMode): Tool {
|
|
64
|
+
const tool: Tool = {
|
|
65
|
+
name: "sift-light",
|
|
66
|
+
title: "sift-light",
|
|
67
|
+
description: outputMode === "model" ? SIFT_LIGHT_MODEL_DESCRIPTION : SIFT_LIGHT_DESCRIPTION,
|
|
68
|
+
// TypeBox and MCP both consume JSON Schema, but their TypeScript declarations are intentionally unrelated.
|
|
69
|
+
// SAFETY: siftLightSchema is runtime-validated TypeBox JSON Schema and matches MCP's input schema shape.
|
|
70
|
+
// oxlint-disable-next-line no-unsafe-type-assertion -- this is the checked JSON Schema adapter boundary
|
|
71
|
+
inputSchema: siftLightSchema as unknown as Tool["inputSchema"],
|
|
72
|
+
annotations: {
|
|
73
|
+
title: "sift-light",
|
|
74
|
+
readOnlyHint: true,
|
|
75
|
+
destructiveHint: false,
|
|
76
|
+
idempotentHint: true,
|
|
77
|
+
openWorldHint: false,
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
if (outputMode === "structured") tool.outputSchema = SIFT_LIGHT_OUTPUT_SCHEMA;
|
|
81
|
+
return tool;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface SiftLightMcpService {
|
|
85
|
+
search(
|
|
86
|
+
input: SiftLightInput,
|
|
87
|
+
cwd: string,
|
|
88
|
+
signal?: AbortSignal,
|
|
89
|
+
options?: SiftLightSearchOptions,
|
|
90
|
+
): Promise<SiftLightResult>;
|
|
91
|
+
shutdown(): Promise<void>;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function createDefaultSiftLightMcpService(
|
|
95
|
+
semanticJudge?: SemanticJudgeIntegration,
|
|
96
|
+
): SiftLightMcpService {
|
|
97
|
+
const resolvedSemanticJudge =
|
|
98
|
+
semanticJudge ?? createDisabledSemanticJudgeIntegration(DEFAULT_SEMANTIC_JUDGE_CONFIG);
|
|
99
|
+
return new SiftLightService({
|
|
100
|
+
runRipgrep: createRipgrepRunner(),
|
|
101
|
+
structure: createCtagsStructureProvider(),
|
|
102
|
+
semanticJudge: resolvedSemanticJudge,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function errorMessage(error: unknown): string {
|
|
107
|
+
return error instanceof Error ? error.message : String(error);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function validationError(value: unknown): RequestContractError | undefined {
|
|
111
|
+
if (Value.Check(siftLightSchema, value)) return undefined;
|
|
112
|
+
const first = Value.Errors(siftLightSchema, value)[0];
|
|
113
|
+
return schemaContractError(
|
|
114
|
+
value,
|
|
115
|
+
first?.instancePath,
|
|
116
|
+
first ? `expected ${first.message}` : "the request does not match the advertised schema",
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function parseSiftLightInput(value: unknown): SiftLightInput {
|
|
121
|
+
const failure = validationError(value);
|
|
122
|
+
if (failure) throw failure;
|
|
123
|
+
// Value.Check has validated the complete public schema before this boundary.
|
|
124
|
+
// oxlint-disable-next-line no-unsafe-type-assertion -- TypeBox's inferred shape feeds the existing service contract
|
|
125
|
+
return value as SiftLightInput;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function toolError(error: unknown, outputMode: SiftLightMcpOutputMode) {
|
|
129
|
+
const contractProjection = isSiftLightDiagnosticError(error)
|
|
130
|
+
? requestContractProjection(error)
|
|
131
|
+
: undefined;
|
|
132
|
+
const text = contractProjection?.text ?? modelErrorText(error);
|
|
133
|
+
const structuredError = contractProjection?.details;
|
|
134
|
+
return {
|
|
135
|
+
content: [{ type: "text" as const, text }],
|
|
136
|
+
isError: true,
|
|
137
|
+
...(outputMode === "structured" && structuredError
|
|
138
|
+
? {
|
|
139
|
+
structuredContent: {
|
|
140
|
+
text,
|
|
141
|
+
details: {
|
|
142
|
+
version: 1,
|
|
143
|
+
mode: structuredError.mode ?? "auto",
|
|
144
|
+
status: "failed" as const,
|
|
145
|
+
totalMatches: 0,
|
|
146
|
+
storedMatches: 0,
|
|
147
|
+
totalFiles: 0,
|
|
148
|
+
returnedMatches: 0,
|
|
149
|
+
snapshotComplete: false,
|
|
150
|
+
error: structuredError,
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
}
|
|
154
|
+
: {}),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function createSiftLightMcpServer(
|
|
159
|
+
service: SiftLightMcpService,
|
|
160
|
+
cwd: string,
|
|
161
|
+
outputMode: SiftLightMcpOutputMode = DEFAULT_MCP_OUTPUT_MODE,
|
|
162
|
+
): McpServer {
|
|
163
|
+
const resolvedOutputMode = parseSiftLightMcpOutputMode(outputMode);
|
|
164
|
+
const tool = siftLightTool(resolvedOutputMode);
|
|
165
|
+
const server = new McpServer(
|
|
166
|
+
{ name: "sift-light", version: SIFT_LIGHT_VERSION },
|
|
167
|
+
{
|
|
168
|
+
capabilities: { tools: {} },
|
|
169
|
+
instructions: siftLightMcpInstructions(resolvedOutputMode),
|
|
170
|
+
},
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
server.server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
174
|
+
tools: [tool],
|
|
175
|
+
}));
|
|
176
|
+
|
|
177
|
+
server.server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
178
|
+
if (request.params.name !== tool.name) {
|
|
179
|
+
return toolError(new Error(`Unknown tool: ${request.params.name}`), resolvedOutputMode);
|
|
180
|
+
}
|
|
181
|
+
try {
|
|
182
|
+
const input = parseSiftLightInput(request.params.arguments ?? {});
|
|
183
|
+
const result = await service.search(input, cwd, extra.signal, {
|
|
184
|
+
modelOutput: resolvedOutputMode === "model",
|
|
185
|
+
modelSource:
|
|
186
|
+
resolvedOutputMode === "model" &&
|
|
187
|
+
(input.cursor !== undefined || input.limit !== undefined),
|
|
188
|
+
});
|
|
189
|
+
const text = resolvedOutputMode === "model" ? compactMcpModelText(result) : result.text;
|
|
190
|
+
const content = [{ type: "text" as const, text }];
|
|
191
|
+
if (resolvedOutputMode !== "structured") return { content };
|
|
192
|
+
// Clients may expose only structuredContent to their model. Both representations
|
|
193
|
+
// must use the same final page, after service-level formatting and redaction.
|
|
194
|
+
return { content, structuredContent: { text: result.text, details: result.details } };
|
|
195
|
+
} catch (error) {
|
|
196
|
+
return toolError(error, resolvedOutputMode);
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
return server;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
interface McpSession {
|
|
204
|
+
protocol: McpServer;
|
|
205
|
+
service: SiftLightMcpService;
|
|
206
|
+
transport: StreamableHTTPServerTransport;
|
|
207
|
+
lastAccessedAt: number;
|
|
208
|
+
activeRequests: number;
|
|
209
|
+
cleanup?: Promise<void>;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function settledErrors(results: readonly PromiseSettledResult<unknown>[]): unknown[] {
|
|
213
|
+
return results.flatMap((result) => (result.status === "rejected" ? [result.reason] : []));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function cleanupSession(sessions: Map<string, McpSession>, session: McpSession): Promise<void> {
|
|
217
|
+
if (session.cleanup) return session.cleanup;
|
|
218
|
+
session.cleanup = Promise.resolve().then(async () => {
|
|
219
|
+
const sessionId = session.transport.sessionId;
|
|
220
|
+
if (sessionId && sessions.get(sessionId) === session) sessions.delete(sessionId);
|
|
221
|
+
const errors = settledErrors(
|
|
222
|
+
await Promise.allSettled([session.protocol.close(), session.service.shutdown()]),
|
|
223
|
+
);
|
|
224
|
+
if (errors.length > 0) throw new AggregateError(errors, "MCP session cleanup failed");
|
|
225
|
+
return undefined;
|
|
226
|
+
});
|
|
227
|
+
return session.cleanup;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
interface McpSessionState {
|
|
231
|
+
readonly sessions: Map<string, McpSession>;
|
|
232
|
+
readonly ownedSessions: Set<McpSession>;
|
|
233
|
+
readonly maxSessions: number;
|
|
234
|
+
readonly idleTimeoutMs: number;
|
|
235
|
+
readonly allowedOrigins: ReadonlySet<string>;
|
|
236
|
+
readonly outputMode: SiftLightMcpOutputMode;
|
|
237
|
+
readonly cleanupErrors: unknown[];
|
|
238
|
+
pendingInitializations: number;
|
|
239
|
+
closing: boolean;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function recordCleanupError(state: McpSessionState, error: unknown): void {
|
|
243
|
+
state.cleanupErrors.push(error);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function ignoreCleanupError(): void {}
|
|
247
|
+
|
|
248
|
+
function cleanupOwnedSession(state: McpSessionState, session: McpSession): Promise<void> {
|
|
249
|
+
return cleanupSession(state.sessions, session).finally(() => state.ownedSessions.delete(session));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function sweepIdleSessions(state: McpSessionState, now = Date.now()): Promise<void> {
|
|
253
|
+
const expired = [...state.sessions.values()].filter(
|
|
254
|
+
(session) =>
|
|
255
|
+
session.activeRequests === 0 && now - session.lastAccessedAt >= state.idleTimeoutMs,
|
|
256
|
+
);
|
|
257
|
+
const errors = settledErrors(
|
|
258
|
+
await Promise.allSettled(expired.map((session) => cleanupOwnedSession(state, session))),
|
|
259
|
+
);
|
|
260
|
+
state.cleanupErrors.push(...errors);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function useSession(session: McpSession, operation: () => Promise<void>): Promise<void> {
|
|
264
|
+
session.activeRequests += 1;
|
|
265
|
+
session.lastAccessedAt = Date.now();
|
|
266
|
+
try {
|
|
267
|
+
await operation();
|
|
268
|
+
} finally {
|
|
269
|
+
session.activeRequests -= 1;
|
|
270
|
+
session.lastAccessedAt = Date.now();
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export interface SiftLightMcpHttpOptions {
|
|
275
|
+
cwd?: string;
|
|
276
|
+
host?: string;
|
|
277
|
+
port?: number;
|
|
278
|
+
allowedOrigins?: readonly string[];
|
|
279
|
+
maxSessions?: number;
|
|
280
|
+
sessionIdleTimeoutMs?: number;
|
|
281
|
+
outputMode?: SiftLightMcpOutputMode;
|
|
282
|
+
createService?: () => SiftLightMcpService;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export interface RunningSiftLightMcpServer {
|
|
286
|
+
readonly httpServer: Server;
|
|
287
|
+
readonly cwd: string;
|
|
288
|
+
close(): Promise<void>;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function requestHeader(request: IncomingMessage, name: string): string | undefined {
|
|
292
|
+
const value = request.headers[name];
|
|
293
|
+
return Array.isArray(value) ? value[0] : value;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function admitOrigin(
|
|
297
|
+
request: IncomingMessage,
|
|
298
|
+
response: ServerResponse,
|
|
299
|
+
allowedOrigins: ReadonlySet<string>,
|
|
300
|
+
): boolean {
|
|
301
|
+
const origin = requestHeader(request, "origin");
|
|
302
|
+
if (origin === undefined) return true;
|
|
303
|
+
if (!allowedOrigins.has(origin)) {
|
|
304
|
+
writeJsonError(response, 403, "MCP request Origin is not allowed");
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
response.setHeader("access-control-allow-origin", origin);
|
|
308
|
+
response.setHeader("access-control-expose-headers", "Mcp-Session-Id, MCP-Protocol-Version");
|
|
309
|
+
response.setHeader("vary", "Origin");
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
type ParsedMcpBody = { ok: true; value: unknown } | { ok: false; message: string };
|
|
314
|
+
|
|
315
|
+
async function readJsonBody(request: IncomingMessage): Promise<ParsedMcpBody> {
|
|
316
|
+
const contentLength = request.headers["content-length"];
|
|
317
|
+
if (contentLength !== undefined) {
|
|
318
|
+
const length = Number(contentLength);
|
|
319
|
+
if (!Number.isSafeInteger(length) || length < 0)
|
|
320
|
+
return { ok: false, message: "Invalid Content-Length" };
|
|
321
|
+
if (length > MAX_MCP_BODY_BYTES)
|
|
322
|
+
return { ok: false, message: "MCP request body exceeds the size limit" };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const chunks: Buffer[] = [];
|
|
326
|
+
let total = 0;
|
|
327
|
+
for await (const chunk of request) {
|
|
328
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
329
|
+
total += bytes.byteLength;
|
|
330
|
+
if (total > MAX_MCP_BODY_BYTES)
|
|
331
|
+
return { ok: false, message: "MCP request body exceeds the size limit" };
|
|
332
|
+
chunks.push(bytes);
|
|
333
|
+
}
|
|
334
|
+
if (chunks.length === 0) return { ok: true, value: undefined };
|
|
335
|
+
|
|
336
|
+
let body: string;
|
|
337
|
+
try {
|
|
338
|
+
body = new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks));
|
|
339
|
+
} catch {
|
|
340
|
+
return { ok: false, message: "MCP request body must be valid UTF-8" };
|
|
341
|
+
}
|
|
342
|
+
try {
|
|
343
|
+
return { ok: true, value: JSON.parse(body) as unknown };
|
|
344
|
+
} catch {
|
|
345
|
+
return { ok: false, message: "MCP request body must be valid JSON" };
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function writeJsonError(response: ServerResponse, status: number, message: string): void {
|
|
350
|
+
if (response.headersSent) return;
|
|
351
|
+
const body = JSON.stringify({
|
|
352
|
+
jsonrpc: "2.0",
|
|
353
|
+
error: { code: -32000, message },
|
|
354
|
+
id: null,
|
|
355
|
+
});
|
|
356
|
+
response.writeHead(status, {
|
|
357
|
+
"content-type": "application/json",
|
|
358
|
+
"content-length": Buffer.byteLength(body),
|
|
359
|
+
});
|
|
360
|
+
response.end(body);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function reportHttpFailure(response: ServerResponse, error: unknown): void {
|
|
364
|
+
process.stderr.write(`sift-light MCP request failed: ${errorMessage(error)}\n`);
|
|
365
|
+
if (!response.headersSent) writeJsonError(response, 500, "MCP request failed");
|
|
366
|
+
else response.destroy();
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function requestPath(request: IncomingMessage): string {
|
|
370
|
+
return new URL(request.url ?? SIFT_LIGHT_MCP_PATH, "http://sift-light.local").pathname;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async function handleMcpRequest(
|
|
374
|
+
request: IncomingMessage,
|
|
375
|
+
response: ServerResponse,
|
|
376
|
+
state: McpSessionState,
|
|
377
|
+
createService: () => SiftLightMcpService,
|
|
378
|
+
cwd: string,
|
|
379
|
+
): Promise<void> {
|
|
380
|
+
if (!admitOrigin(request, response, state.allowedOrigins)) return;
|
|
381
|
+
if (state.closing) {
|
|
382
|
+
writeJsonError(response, 503, "MCP server is shutting down");
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
let path: string;
|
|
387
|
+
try {
|
|
388
|
+
path = requestPath(request);
|
|
389
|
+
} catch {
|
|
390
|
+
writeJsonError(response, 400, "MCP request URL is invalid");
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
if (path !== SIFT_LIGHT_MCP_PATH) {
|
|
394
|
+
writeJsonError(response, 404, "MCP endpoint not found");
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (request.method === "OPTIONS") {
|
|
399
|
+
response.setHeader("access-control-allow-methods", "GET, POST, DELETE, OPTIONS");
|
|
400
|
+
const requestedHeaders = requestHeader(request, "access-control-request-headers");
|
|
401
|
+
if (requestedHeaders) response.setHeader("access-control-allow-headers", requestedHeaders);
|
|
402
|
+
response.statusCode = 204;
|
|
403
|
+
response.end();
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const sessionId = requestHeader(request, "mcp-session-id");
|
|
408
|
+
if (request.method === "POST") {
|
|
409
|
+
const parsedBody = await readJsonBody(request);
|
|
410
|
+
if (!parsedBody.ok) {
|
|
411
|
+
writeJsonError(response, 400, parsedBody.message);
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
const body = parsedBody.value;
|
|
415
|
+
|
|
416
|
+
let session = sessionId ? state.sessions.get(sessionId) : undefined;
|
|
417
|
+
let createdSession: McpSession | undefined;
|
|
418
|
+
let initializationReserved = false;
|
|
419
|
+
if (!session) {
|
|
420
|
+
if (sessionId) {
|
|
421
|
+
writeJsonError(response, 404, "MCP session not found");
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
if (!isInitializeRequest(body)) {
|
|
425
|
+
writeJsonError(response, 400, "MCP initialization is required before tool calls");
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
await sweepIdleSessions(state);
|
|
430
|
+
if (state.closing) {
|
|
431
|
+
writeJsonError(response, 503, "MCP server is shutting down");
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
if (state.sessions.size + state.pendingInitializations >= state.maxSessions) {
|
|
435
|
+
writeJsonError(
|
|
436
|
+
response,
|
|
437
|
+
503,
|
|
438
|
+
"MCP session limit reached; retry after an idle session expires",
|
|
439
|
+
);
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
state.pendingInitializations += 1;
|
|
443
|
+
initializationReserved = true;
|
|
444
|
+
|
|
445
|
+
let pendingSession: McpSession | undefined;
|
|
446
|
+
try {
|
|
447
|
+
const service = createService();
|
|
448
|
+
const transport = new StreamableHTTPServerTransport({
|
|
449
|
+
sessionIdGenerator: () => randomUUID(),
|
|
450
|
+
onsessioninitialized: (initializedSessionId) => {
|
|
451
|
+
if (!pendingSession) throw new Error("MCP session initialized before registration");
|
|
452
|
+
state.sessions.set(initializedSessionId, pendingSession);
|
|
453
|
+
if (initializationReserved) {
|
|
454
|
+
state.pendingInitializations -= 1;
|
|
455
|
+
initializationReserved = false;
|
|
456
|
+
}
|
|
457
|
+
},
|
|
458
|
+
});
|
|
459
|
+
const protocol = createSiftLightMcpServer(service, cwd, state.outputMode);
|
|
460
|
+
pendingSession = {
|
|
461
|
+
protocol,
|
|
462
|
+
service,
|
|
463
|
+
transport,
|
|
464
|
+
lastAccessedAt: Date.now(),
|
|
465
|
+
activeRequests: 0,
|
|
466
|
+
};
|
|
467
|
+
createdSession = pendingSession;
|
|
468
|
+
state.ownedSessions.add(pendingSession);
|
|
469
|
+
// MCP transport exposes its lifecycle hook as an onclose property.
|
|
470
|
+
// oxlint-disable-next-line unicorn/prefer-add-event-listener -- required by the upstream transport API
|
|
471
|
+
transport.onclose = () => {
|
|
472
|
+
const closedSession = pendingSession;
|
|
473
|
+
if (!closedSession) return;
|
|
474
|
+
const cleanupAlreadyOwned = closedSession.cleanup !== undefined;
|
|
475
|
+
const cleanup = cleanupOwnedSession(state, closedSession);
|
|
476
|
+
void cleanup.catch(
|
|
477
|
+
cleanupAlreadyOwned
|
|
478
|
+
? ignoreCleanupError
|
|
479
|
+
: (error: unknown) => recordCleanupError(state, error),
|
|
480
|
+
);
|
|
481
|
+
};
|
|
482
|
+
// The SDK's transport declaration is not exact-optional-compatible with this repository's strict settings.
|
|
483
|
+
// SAFETY: StreamableHTTPServerTransport implements the MCP Transport contract; only optional callbacks differ in declarations.
|
|
484
|
+
// oxlint-disable-next-line no-unsafe-type-assertion -- upstream transport boundary
|
|
485
|
+
await protocol.connect(transport as unknown as Transport);
|
|
486
|
+
session = pendingSession;
|
|
487
|
+
} catch (error) {
|
|
488
|
+
if (initializationReserved) {
|
|
489
|
+
state.pendingInitializations -= 1;
|
|
490
|
+
initializationReserved = false;
|
|
491
|
+
}
|
|
492
|
+
if (pendingSession) {
|
|
493
|
+
try {
|
|
494
|
+
await cleanupOwnedSession(state, pendingSession);
|
|
495
|
+
} catch (cleanupError) {
|
|
496
|
+
recordCleanupError(state, cleanupError);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
reportHttpFailure(response, error);
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
let requestFailed = false;
|
|
505
|
+
try {
|
|
506
|
+
await useSession(session, () => session.transport.handleRequest(request, response, body));
|
|
507
|
+
} catch (error) {
|
|
508
|
+
requestFailed = true;
|
|
509
|
+
reportHttpFailure(response, error);
|
|
510
|
+
} finally {
|
|
511
|
+
if (createdSession && (requestFailed || createdSession.transport.sessionId === undefined)) {
|
|
512
|
+
try {
|
|
513
|
+
await cleanupOwnedSession(state, createdSession);
|
|
514
|
+
} catch (cleanupError) {
|
|
515
|
+
recordCleanupError(state, cleanupError);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
if (initializationReserved) state.pendingInitializations -= 1;
|
|
519
|
+
}
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
if (request.method === "GET" || request.method === "DELETE") {
|
|
524
|
+
if (!sessionId) {
|
|
525
|
+
writeJsonError(response, 400, "MCP session ID is required");
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
const session = state.sessions.get(sessionId);
|
|
529
|
+
if (!session) {
|
|
530
|
+
writeJsonError(response, 404, "MCP session not found");
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
try {
|
|
534
|
+
await useSession(session, () => session.transport.handleRequest(request, response));
|
|
535
|
+
} catch (error) {
|
|
536
|
+
reportHttpFailure(response, error);
|
|
537
|
+
}
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
response.setHeader("allow", "GET, POST, DELETE, OPTIONS");
|
|
542
|
+
writeJsonError(response, 405, "MCP method not allowed");
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
export async function startSiftLightMcpServer(
|
|
546
|
+
options: SiftLightMcpHttpOptions = {},
|
|
547
|
+
): Promise<RunningSiftLightMcpServer> {
|
|
548
|
+
const cwd = options.cwd ?? process.cwd();
|
|
549
|
+
const maxSessions = options.maxSessions ?? DEFAULT_MCP_MAX_SESSIONS;
|
|
550
|
+
const idleTimeoutMs = options.sessionIdleTimeoutMs ?? DEFAULT_MCP_SESSION_IDLE_TIMEOUT_MS;
|
|
551
|
+
if (!Number.isSafeInteger(maxSessions) || maxSessions < 1)
|
|
552
|
+
throw new Error("maxSessions must be a positive safe integer");
|
|
553
|
+
if (!Number.isSafeInteger(idleTimeoutMs) || idleTimeoutMs < 1)
|
|
554
|
+
throw new Error("sessionIdleTimeoutMs must be a positive safe integer");
|
|
555
|
+
const state: McpSessionState = {
|
|
556
|
+
sessions: new Map(),
|
|
557
|
+
ownedSessions: new Set(),
|
|
558
|
+
maxSessions,
|
|
559
|
+
idleTimeoutMs,
|
|
560
|
+
allowedOrigins: new Set(options.allowedOrigins ?? []),
|
|
561
|
+
outputMode: parseSiftLightMcpOutputMode(options.outputMode),
|
|
562
|
+
cleanupErrors: [],
|
|
563
|
+
pendingInitializations: 0,
|
|
564
|
+
closing: false,
|
|
565
|
+
};
|
|
566
|
+
const createService = options.createService ?? createDefaultSiftLightMcpService;
|
|
567
|
+
const httpServer = createServer((request, response) => {
|
|
568
|
+
void handleMcpRequest(request, response, state, createService, cwd).catch((error: unknown) => {
|
|
569
|
+
reportHttpFailure(response, error);
|
|
570
|
+
});
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
const host = options.host ?? DEFAULT_MCP_HOST;
|
|
574
|
+
const port = options.port ?? DEFAULT_MCP_PORT;
|
|
575
|
+
try {
|
|
576
|
+
await new Promise<void>((resolve, reject) => {
|
|
577
|
+
const onError = (error: Error) => {
|
|
578
|
+
httpServer.off("listening", onListening);
|
|
579
|
+
reject(error);
|
|
580
|
+
};
|
|
581
|
+
const onListening = () => {
|
|
582
|
+
httpServer.off("error", onError);
|
|
583
|
+
resolve();
|
|
584
|
+
};
|
|
585
|
+
httpServer.once("error", onError);
|
|
586
|
+
httpServer.once("listening", onListening);
|
|
587
|
+
httpServer.listen(port, host);
|
|
588
|
+
});
|
|
589
|
+
} catch (error) {
|
|
590
|
+
state.closing = true;
|
|
591
|
+
throw error;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const sweepIntervalMs = Math.min(Math.max(Math.floor(idleTimeoutMs / 2), 10), 60_000);
|
|
595
|
+
const sweepTimer = setInterval(() => {
|
|
596
|
+
void sweepIdleSessions(state);
|
|
597
|
+
}, sweepIntervalMs);
|
|
598
|
+
sweepTimer.unref();
|
|
599
|
+
|
|
600
|
+
let closePromise: Promise<void> | undefined;
|
|
601
|
+
const close = (): Promise<void> => {
|
|
602
|
+
if (closePromise) return closePromise;
|
|
603
|
+
closePromise = (async () => {
|
|
604
|
+
state.closing = true;
|
|
605
|
+
clearInterval(sweepTimer);
|
|
606
|
+
const stopListening = new Promise<void>((resolve, reject) => {
|
|
607
|
+
httpServer.close((error) => (error ? reject(error) : resolve()));
|
|
608
|
+
});
|
|
609
|
+
const initialCleanup = await Promise.allSettled([
|
|
610
|
+
...[...state.ownedSessions].map((session) => cleanupOwnedSession(state, session)),
|
|
611
|
+
stopListening,
|
|
612
|
+
]);
|
|
613
|
+
const finalCleanup = await Promise.allSettled(
|
|
614
|
+
[...state.ownedSessions].map((session) => cleanupOwnedSession(state, session)),
|
|
615
|
+
);
|
|
616
|
+
const errors = [
|
|
617
|
+
...state.cleanupErrors,
|
|
618
|
+
...settledErrors(initialCleanup),
|
|
619
|
+
...settledErrors(finalCleanup),
|
|
620
|
+
];
|
|
621
|
+
if (errors.length > 0) throw new AggregateError(errors, "MCP server shutdown failed");
|
|
622
|
+
})();
|
|
623
|
+
return closePromise;
|
|
624
|
+
};
|
|
625
|
+
|
|
626
|
+
return {
|
|
627
|
+
httpServer,
|
|
628
|
+
cwd,
|
|
629
|
+
close,
|
|
630
|
+
};
|
|
631
|
+
}
|