veryfront 0.1.159 → 0.1.161

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.
@@ -0,0 +1,163 @@
1
+ import * as dntShim from "../../_dnt.shims.js";
2
+ import type { ToolExecutionContext } from "../tool/index.js";
3
+ import { VeryfrontError } from "../security/input-validation/errors.js";
4
+ import { validateContentType } from "../security/input-validation/limits.js";
5
+ import { SessionManager } from "./session.js";
6
+
7
+ const MAX_REQUEST_BODY_SIZE = 1_048_576; // 1 MB
8
+ const JSON_CONTENT_TYPE = "application/json";
9
+
10
+ type JSONRPCParams = Record<string, unknown> | unknown[];
11
+
12
+ interface JSONRPCRequest {
13
+ jsonrpc: "2.0";
14
+ id?: string | number;
15
+ method: string;
16
+ params?: JSONRPCParams;
17
+ }
18
+
19
+ interface JSONRPCResponse {
20
+ jsonrpc: "2.0";
21
+ id?: string | number;
22
+ result?: unknown;
23
+ error?: {
24
+ code: number;
25
+ message: string;
26
+ data?: unknown;
27
+ };
28
+ }
29
+
30
+ export interface MCPHTTPTransportDependencies {
31
+ authEnabled: boolean;
32
+ getCORSHeaders: (requestOrigin?: string | null) => Record<string, string>;
33
+ validateAuth: (request: dntShim.Request) => Promise<boolean>;
34
+ handleRequest: (
35
+ request: JSONRPCRequest,
36
+ context?: ToolExecutionContext,
37
+ ) => Promise<JSONRPCResponse>;
38
+ extractRequestContext: (request: dntShim.Request) => ToolExecutionContext | undefined;
39
+ isOriginAllowed: (requestOrigin?: string | null) => boolean;
40
+ sessionCapabilities: Map<string, Record<string, unknown>>;
41
+ sessionManager: SessionManager;
42
+ }
43
+
44
+ function createJSONResponse(body: unknown, init?: dntShim.ResponseInit): dntShim.Response {
45
+ const headers = new dntShim.Headers(init?.headers);
46
+ headers.set("Content-Type", JSON_CONTENT_TYPE);
47
+ return new dntShim.Response(JSON.stringify(body), { ...init, headers });
48
+ }
49
+
50
+ function createJSONRPCErrorResponse(status: number, code: number, message: string): dntShim.Response {
51
+ return createJSONResponse(
52
+ {
53
+ jsonrpc: "2.0",
54
+ id: null,
55
+ error: { code, message },
56
+ },
57
+ { status },
58
+ );
59
+ }
60
+
61
+ export function createMCPHTTPHandler(
62
+ dependencies: MCPHTTPTransportDependencies,
63
+ ): (request: dntShim.Request) => Promise<dntShim.Response> {
64
+ const {
65
+ authEnabled,
66
+ getCORSHeaders,
67
+ validateAuth,
68
+ handleRequest,
69
+ extractRequestContext,
70
+ isOriginAllowed,
71
+ sessionCapabilities,
72
+ sessionManager,
73
+ } = dependencies;
74
+
75
+ return async (request: dntShim.Request) => {
76
+ const requestOrigin = request.headers.get("Origin");
77
+
78
+ if (request.method === "OPTIONS") {
79
+ return new dntShim.Response(null, { status: 204, headers: getCORSHeaders(requestOrigin) });
80
+ }
81
+
82
+ if (!isOriginAllowed(requestOrigin)) {
83
+ return createJSONRPCErrorResponse(403, -32600, "Forbidden: Origin not allowed");
84
+ }
85
+
86
+ if (authEnabled) {
87
+ const authorized = await validateAuth(request);
88
+ if (!authorized) return new dntShim.Response("Unauthorized", { status: 401 });
89
+ }
90
+
91
+ if (request.method === "DELETE") {
92
+ const sessionId = request.headers.get("MCP-Session-Id");
93
+ if (sessionId) {
94
+ sessionManager.terminate(sessionId);
95
+ sessionCapabilities.delete(sessionId);
96
+ }
97
+ return new dntShim.Response(null, { status: 200, headers: getCORSHeaders(requestOrigin) });
98
+ }
99
+
100
+ if (request.method !== "POST") {
101
+ return new dntShim.Response("Method Not Allowed", { status: 405 });
102
+ }
103
+
104
+ const contentLength = request.headers.get("content-length");
105
+ if (contentLength && Number(contentLength) > MAX_REQUEST_BODY_SIZE) {
106
+ return createJSONRPCErrorResponse(413, -32600, "Request body too large");
107
+ }
108
+
109
+ try {
110
+ validateContentType(request, JSON_CONTENT_TYPE);
111
+ } catch (error) {
112
+ const message = error instanceof VeryfrontError ? error.message : "Invalid Content-Type";
113
+ return createJSONRPCErrorResponse(400, -32700, message);
114
+ }
115
+
116
+ let rpcRequest: JSONRPCRequest;
117
+ try {
118
+ const bodyText = await request.text();
119
+ if (bodyText.length > MAX_REQUEST_BODY_SIZE) {
120
+ return createJSONRPCErrorResponse(413, -32600, "Request body too large");
121
+ }
122
+ rpcRequest = JSON.parse(bodyText) as JSONRPCRequest;
123
+ } catch (_) {
124
+ return createJSONRPCErrorResponse(400, -32700, "Parse error");
125
+ }
126
+
127
+ const responseHeaders: Record<string, string> = {
128
+ ...getCORSHeaders(requestOrigin),
129
+ };
130
+
131
+ if (rpcRequest.method === "initialize") {
132
+ const context = extractRequestContext(request);
133
+ const rpcResponse = await handleRequest(rpcRequest, context);
134
+ const clientCaps =
135
+ ((rpcRequest.params as Record<string, unknown> | undefined)?.capabilities ??
136
+ {}) as Record<string, unknown>;
137
+ const sessionId = sessionManager.create();
138
+ sessionCapabilities.set(sessionId, clientCaps);
139
+ responseHeaders["MCP-Session-Id"] = sessionId;
140
+ return createJSONResponse(rpcResponse, { headers: responseHeaders });
141
+ }
142
+
143
+ if (sessionManager.size > 0) {
144
+ const sessionId = request.headers.get("MCP-Session-Id");
145
+ if (!sessionId) {
146
+ return createJSONRPCErrorResponse(400, -32600, "Missing MCP-Session-Id header");
147
+ }
148
+ if (!sessionManager.isValid(sessionId)) {
149
+ return createJSONRPCErrorResponse(404, -32600, "Session not found or expired");
150
+ }
151
+ }
152
+
153
+ if (rpcRequest.id === undefined) {
154
+ const context = extractRequestContext(request);
155
+ await handleRequest(rpcRequest, context);
156
+ return new dntShim.Response(null, { status: 202, headers: responseHeaders });
157
+ }
158
+
159
+ const context = extractRequestContext(request);
160
+ const rpcResponse = await handleRequest(rpcRequest, context);
161
+ return createJSONResponse(rpcResponse, { headers: responseHeaders });
162
+ };
163
+ }
@@ -9,18 +9,14 @@ import type { MCPServerConfig, ToolListEntry } from "./types.js";
9
9
  import { createError, toError } from "../errors/veryfront-error.js";
10
10
  import { withSpan } from "../observability/tracing/otlp-setup.js";
11
11
  import { VERSION } from "../utils/version.js";
12
- import { validateContentType } from "../security/input-validation/limits.js";
13
- import { VeryfrontError } from "../security/input-validation/errors.js";
14
12
  import type { IntegrationRuntimeConfig } from "../integrations/types.js";
15
13
  import { logger as baseLogger } from "../utils/index.js";
14
+ import { createMCPHTTPHandler } from "./http-transport.js";
16
15
  import { SessionManager } from "./session.js";
17
16
  import { TaskStore } from "./task-store.js";
18
17
 
19
18
  const logger = baseLogger.component("mcp-server");
20
-
21
- const MAX_REQUEST_BODY_SIZE = 1_048_576; // 1 MB
22
19
  const MAX_CONTEXT_HEADER_LENGTH = 255;
23
- const JSON_CONTENT_TYPE = "application/json";
24
20
  const END_USER_ID_PATTERN = /^[a-zA-Z0-9._@-]+$/;
25
21
  const PROJECT_ID_PATTERN = /^[a-zA-Z0-9._-]+$/;
26
22
 
@@ -55,23 +51,6 @@ function toParamsRecord(params: JSONRPCParams | undefined): Record<string, unkno
55
51
  return params;
56
52
  }
57
53
 
58
- function createJSONResponse(body: unknown, init?: dntShim.ResponseInit): dntShim.Response {
59
- const headers = new dntShim.Headers(init?.headers);
60
- headers.set("Content-Type", JSON_CONTENT_TYPE);
61
- return new dntShim.Response(JSON.stringify(body), { ...init, headers });
62
- }
63
-
64
- function createJSONRPCErrorResponse(status: number, code: number, message: string): dntShim.Response {
65
- return createJSONResponse(
66
- {
67
- jsonrpc: "2.0",
68
- id: null,
69
- error: { code, message },
70
- },
71
- { status },
72
- );
73
- }
74
-
75
54
  function readAllowedHeader(
76
55
  request: dntShim.Request,
77
56
  headerName: string,
@@ -628,107 +607,20 @@ export class MCPServer {
628
607
  }
629
608
 
630
609
  createHTTPHandler(): (request: dntShim.Request) => Promise<dntShim.Response> {
631
- return async (request: dntShim.Request) => {
632
- const requestOrigin = request.headers.get("Origin");
633
-
634
- // CORS preflight
635
- if (request.method === "OPTIONS") {
636
- return new dntShim.Response(null, { status: 204, headers: this.getCORSHeaders(requestOrigin) });
637
- }
638
-
639
- // Origin validation (DNS rebinding protection)
640
- if (requestOrigin && this.config.cors?.enabled && this.config.cors.origins?.length) {
641
- if (!this.config.cors.origins.includes(requestOrigin)) {
642
- return createJSONRPCErrorResponse(403, -32600, "Forbidden: Origin not allowed");
643
- }
644
- }
645
-
646
- // Auth check (applies to all methods including DELETE)
647
- if (this.config.auth?.type && this.config.auth.type !== "none") {
648
- const authorized = await this.validateAuth(request);
649
- if (!authorized) return new dntShim.Response("Unauthorized", { status: 401 });
650
- }
651
-
652
- // DELETE = terminate session
653
- if (request.method === "DELETE") {
654
- const sessionId = request.headers.get("MCP-Session-Id");
655
- if (sessionId) {
656
- this.sessionManager.terminate(sessionId);
657
- this.sessionCapabilities.delete(sessionId);
658
- }
659
- return new dntShim.Response(null, { status: 200, headers: this.getCORSHeaders(requestOrigin) });
660
- }
661
-
662
- // Only POST allowed for JSON-RPC messages
663
- if (request.method !== "POST") {
664
- return new dntShim.Response("Method Not Allowed", { status: 405 });
665
- }
666
-
667
- // Enforce request body size limit (fast path via Content-Length header)
668
- const contentLength = request.headers.get("content-length");
669
- if (contentLength && Number(contentLength) > MAX_REQUEST_BODY_SIZE) {
670
- return createJSONRPCErrorResponse(413, -32600, "Request body too large");
671
- }
672
-
673
- try {
674
- validateContentType(request, JSON_CONTENT_TYPE);
675
- } catch (error) {
676
- const message = error instanceof VeryfrontError ? error.message : "Invalid Content-Type";
677
- return createJSONRPCErrorResponse(400, -32700, message);
678
- }
679
-
680
- let rpcRequest: JSONRPCRequest;
681
- try {
682
- const bodyText = await request.text();
683
- if (bodyText.length > MAX_REQUEST_BODY_SIZE) {
684
- return createJSONRPCErrorResponse(413, -32600, "Request body too large");
685
- }
686
- rpcRequest = JSON.parse(bodyText) as JSONRPCRequest;
687
- } catch (_) {
688
- // expected: malformed JSON in request body
689
- return createJSONRPCErrorResponse(400, -32700, "Parse error");
690
- }
691
-
692
- // Session management: initialize creates session, everything else requires it
693
- const responseHeaders: Record<string, string> = {
694
- ...this.getCORSHeaders(requestOrigin),
695
- };
696
-
697
- if (rpcRequest.method === "initialize") {
698
- const context = this.extractRequestContext(request);
699
- const rpcResponse = await this.handleRequest(rpcRequest, context);
700
- const clientCaps =
701
- toParamsRecord(rpcRequest.params).capabilities as Record<string, unknown> ??
702
- {};
703
- const sessionId = this.sessionManager.create();
704
- this.sessionCapabilities.set(sessionId, clientCaps);
705
- responseHeaders["MCP-Session-Id"] = sessionId;
706
- return createJSONResponse(rpcResponse, { headers: responseHeaders });
707
- }
708
-
709
- // Post-init: require session ID when sessions are active
710
- if (this.sessionManager.size > 0) {
711
- const sessionId = request.headers.get("MCP-Session-Id");
712
- if (!sessionId) {
713
- return createJSONRPCErrorResponse(400, -32600, "Missing MCP-Session-Id header");
714
- }
715
- if (!this.sessionManager.isValid(sessionId)) {
716
- return createJSONRPCErrorResponse(404, -32600, "Session not found or expired");
717
- }
718
- }
719
-
720
- // Notifications have no id member — return 202 Accepted
721
- // Note: id:0 is a valid request ID per JSON-RPC 2.0, so check for undefined
722
- if (rpcRequest.id === undefined) {
723
- const context = this.extractRequestContext(request);
724
- await this.handleRequest(rpcRequest, context);
725
- return new dntShim.Response(null, { status: 202, headers: responseHeaders });
726
- }
727
-
728
- const context = this.extractRequestContext(request);
729
- const rpcResponse = await this.handleRequest(rpcRequest, context);
730
- return createJSONResponse(rpcResponse, { headers: responseHeaders });
731
- };
610
+ return createMCPHTTPHandler({
611
+ authEnabled: Boolean(this.config.auth?.type && this.config.auth.type !== "none"),
612
+ getCORSHeaders: (requestOrigin) => this.getCORSHeaders(requestOrigin),
613
+ validateAuth: (request) => this.validateAuth(request),
614
+ handleRequest: (request, context) => this.handleRequest(request, context),
615
+ extractRequestContext: (request) => this.extractRequestContext(request),
616
+ isOriginAllowed: (requestOrigin) =>
617
+ !requestOrigin ||
618
+ !this.config.cors?.enabled ||
619
+ !this.config.cors.origins?.length ||
620
+ this.config.cors.origins.includes(requestOrigin),
621
+ sessionCapabilities: this.sessionCapabilities,
622
+ sessionManager: this.sessionManager,
623
+ });
732
624
  }
733
625
 
734
626
  private extractRequestContext(request: dntShim.Request): ToolExecutionContext | undefined {
@@ -329,6 +329,7 @@ export class StatOperations extends VeryfrontOperationsBase {
329
329
  private buildResolveSearchPatterns(
330
330
  normalizedPath: string,
331
331
  options?: ResolveFileOptions,
332
+ knownExtensionFallback: "exact" | "wildcard" = "exact",
332
333
  ): string[] {
333
334
  const patterns = new Set<string>();
334
335
  const pathWithoutExt = stripKnownExtension(normalizedPath, EXTENSION_PRIORITY);
@@ -338,7 +339,9 @@ export class StatOperations extends VeryfrontOperationsBase {
338
339
  };
339
340
 
340
341
  if (EXTENSION_PRIORITY.some((ext) => normalizedPath.endsWith(ext))) {
341
- addPattern(normalizedPath);
342
+ addPattern(
343
+ knownExtensionFallback === "wildcard" ? `${pathWithoutExt}.*` : normalizedPath,
344
+ );
342
345
  return [...patterns];
343
346
  }
344
347
 
@@ -366,6 +369,7 @@ export class StatOperations extends VeryfrontOperationsBase {
366
369
  private async tryResolveViaApiSearch(
367
370
  normalizedPath: string,
368
371
  options?: ResolveFileOptions,
372
+ knownExtensionFallback: "exact" | "wildcard" = "exact",
369
373
  ): Promise<string | null | undefined> {
370
374
  if (isFrameworkSourcePath(normalizedPath)) {
371
375
  logger.debug("Skipping API search for framework path", { normalizedPath });
@@ -377,7 +381,11 @@ export class StatOperations extends VeryfrontOperationsBase {
377
381
  return undefined;
378
382
  }
379
383
 
380
- const patterns = this.buildResolveSearchPatterns(normalizedPath, options);
384
+ const patterns = this.buildResolveSearchPatterns(
385
+ normalizedPath,
386
+ options,
387
+ knownExtensionFallback,
388
+ );
381
389
  let sawSuccessfulSearch = false;
382
390
 
383
391
  for (const pattern of patterns) {
@@ -573,55 +581,17 @@ export class StatOperations extends VeryfrontOperationsBase {
573
581
  return null;
574
582
  }
575
583
 
576
- // NOTE: Removed optimization that skipped API search for paths with extensions.
577
- // This was causing layout files and other project files to not be found when
578
- // they were missing from the file index (due to cache issues, incomplete fetch, etc.).
579
- // The API pattern search is the fallback to ensure files can still be found.
580
-
581
- if (!this.apiSearchCircuitBreaker.canSearch()) {
582
- logger.warn("API search circuit breaker open, skipping", { normalizedPath });
583
- return null;
584
+ // NOTE: Keep the post-index API fallback aligned with the pre-index helper for extensionless
585
+ // paths, while preserving the older wildcard sibling-extension lookup for known-extension
586
+ // paths. Incomplete file-list snapshots otherwise hide valid files until the cache refreshes.
587
+ const apiResolved = await this.tryResolveViaApiSearch(normalizedPath, options, "wildcard");
588
+ if (typeof apiResolved === "string") {
589
+ this.cache.set(cacheKey, apiResolved);
590
+ return apiResolved;
584
591
  }
585
-
586
- const searchPattern = `${pathWithoutExt}.*`;
587
- logger.debug("Searching for file via API", {
588
- pattern: searchPattern,
589
- normalizedPath,
590
- });
591
-
592
- try {
593
- const matches = await this.client.searchFiles(searchPattern);
594
- this.apiSearchCircuitBreaker.recordSuccess();
595
-
596
- logger.debug("API search result", {
597
- pattern: searchPattern,
598
- matchCount: matches.length,
599
- matches: matches.map((m) => m.path).slice(0, 5),
600
- });
601
-
602
- const sortedMatches = sortPathsByExtensionPriority(matches, EXTENSION_PRIORITY);
603
- const first = sortedMatches[0];
604
- if (first) {
605
- logger.debug("resolveFile found via API search", { path: first.path });
606
- this.cache.set(cacheKey, first.path);
607
- return first.path;
608
- }
609
- } catch (error) {
610
- const result = this.apiSearchCircuitBreaker.recordFailure();
611
- if (result.tripped) {
612
- logger.warn("API search circuit breaker tripped", {
613
- failures: result.failures,
614
- });
615
- }
616
- logger.error("API pattern search failed", { pattern: searchPattern, error });
592
+ if (apiResolved === null) {
593
+ this.cache.set(cacheKey, NOT_FOUND_SENTINEL);
617
594
  }
618
-
619
- logger.debug("resolveFile not found after API search", {
620
- normalizedPath,
621
- pathWithoutExt,
622
- });
623
-
624
- this.cache.set(cacheKey, NOT_FOUND_SENTINEL);
625
595
  return null;
626
596
  }
627
597
  }
@@ -1,3 +1,3 @@
1
1
  // Keep in sync with deno.json version.
2
2
  // scripts/release.ts updates this constant during releases.
3
- export const VERSION = "0.1.159";
3
+ export const VERSION = "0.1.161";