Skip to content

Commit da8afe9

Browse files
committed
fix: harden retry alias canonicalization and endpoint coercion
1 parent 57649cc commit da8afe9

7 files changed

Lines changed: 443 additions & 61 deletions

File tree

AGENTS.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,8 +156,10 @@ Canonical shape (nested):
156156
}
157157
```
158158

159-
Endpoint values must be explicit URLs with schemes, for example
160-
`redis://localhost:6379` for Redis and `http://localhost:8000/mcp` for Graphiti.
159+
Endpoint values must resolve to valid URLs. Config loading performs best-effort
160+
coercion by adding the expected scheme when omitted and defaulting the port only
161+
for scheme-less inputs that do not already include one (`6379` for Redis and
162+
`8000` for Graphiti); explicit disallowed schemes still fail validation.
161163

162164
## Key Files & Their Scope
163165

README.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -235,9 +235,14 @@ top-level aliases remain supported for backward compatibility. Precedence is:
235235
1. `redis.*` (canonical)
236236
2. top-level Graphiti aliases such as `endpoint` and `groupIdPrefix`
237237

238-
Endpoint values must be valid URLs, so include the scheme explicitly - for
239-
example `redis://localhost:6379` for Redis and `http://localhost:8000/mcp` for
240-
Graphiti.
238+
Endpoint values must resolve to valid URLs. The loader applies best-effort
239+
coercion for endpoint-like inputs by trimming whitespace, adding the expected
240+
scheme when omitted, and filling the default port only when a missing-scheme
241+
input also omits a port. For example, `localhost` under `redis.endpoint`
242+
resolves to `redis://localhost:6379`, `cache.internal:6380` resolves to
243+
`redis://cache.internal:6380`, and `graphiti.internal/mcp` under
244+
`graphiti.endpoint` resolves to `http://graphiti.internal:8000/mcp`. Inputs that
245+
still fail URL parsing, or that use a disallowed explicit scheme, are rejected.
241246

242247
### Legacy Top-Level Keys
243248

src/config.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,84 @@ describe("config", () => {
304304
assertEquals(config.redis.endpoint, "rediss://cache.example:6379");
305305
});
306306

307+
it("best-effort coerces missing schemes for graphiti and redis endpoints", () => {
308+
setConfigExplorerAdapterForTesting(() =>
309+
makeAdapter({
310+
searchResult: {
311+
endpoint: "legacy.example/mcp",
312+
redis: {
313+
endpoint: "cache.internal",
314+
},
315+
graphiti: {
316+
endpoint: "graphiti.internal/mcp",
317+
},
318+
},
319+
})
320+
);
321+
322+
const config = loadConfig();
323+
324+
assertEquals(config.endpoint, "http://graphiti.internal:8000/mcp");
325+
assertEquals(config.graphiti.endpoint, "http://graphiti.internal:8000/mcp");
326+
assertEquals(config.redis.endpoint, "redis://cache.internal:6379");
327+
});
328+
329+
it("preserves an explicit port on scheme-less redis endpoints", () => {
330+
setConfigExplorerAdapterForTesting(() =>
331+
makeAdapter({
332+
searchResult: {
333+
redis: {
334+
endpoint: "cache.internal:6380",
335+
},
336+
},
337+
})
338+
);
339+
340+
const config = loadConfig();
341+
342+
assertEquals(config.redis.endpoint, "redis://cache.internal:6380");
343+
});
344+
345+
it("preserves explicit schemes while still requiring an allowed protocol", () => {
346+
setConfigExplorerAdapterForTesting(() =>
347+
makeAdapter({
348+
searchResult: {
349+
graphiti: {
350+
endpoint: "https://secure.example/mcp",
351+
},
352+
redis: {
353+
endpoint: "rediss://cache.example",
354+
},
355+
},
356+
})
357+
);
358+
359+
const config = loadConfig();
360+
361+
assertEquals(config.graphiti.endpoint, "https://secure.example/mcp");
362+
assertEquals(config.redis.endpoint, "rediss://cache.example");
363+
});
364+
365+
it("coerces scheme-relative endpoint inputs before validation", () => {
366+
setConfigExplorerAdapterForTesting(() =>
367+
makeAdapter({
368+
searchResult: {
369+
graphiti: {
370+
endpoint: "//graphiti.internal/mcp",
371+
},
372+
redis: {
373+
endpoint: "//cache.internal",
374+
},
375+
},
376+
})
377+
);
378+
379+
const config = loadConfig();
380+
381+
assertEquals(config.graphiti.endpoint, "http://graphiti.internal:8000/mcp");
382+
assertEquals(config.redis.endpoint, "redis://cache.internal:6379");
383+
});
384+
307385
it("redacts credentials from malformed configured endpoint errors", () => {
308386
setConfigExplorerAdapterForTesting(() =>
309387
makeAdapter({

src/config.ts

Lines changed: 70 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -137,13 +137,25 @@ const parseUrlString = (value: string | undefined): URL | null => {
137137
}
138138
};
139139

140-
const assertExplicitUrl = (
140+
const URL_SCHEME_PREFIX = /^[A-Za-z][A-Za-z\d+\-.]*:\/\//;
141+
142+
const coerceConfiguredUrl = (
141143
value: string | undefined,
142144
fieldName: string,
143-
allowedSchemes?: string[],
144-
): void => {
145-
if (value === undefined) return;
146-
const url = parseUrlString(value);
145+
options: {
146+
allowedSchemes?: string[];
147+
defaultScheme: string;
148+
defaultPort?: string;
149+
},
150+
): string | undefined => {
151+
if (value === undefined) return undefined;
152+
153+
const hasExplicitScheme = URL_SCHEME_PREFIX.test(value);
154+
const candidate = hasExplicitScheme
155+
? value
156+
: `${options.defaultScheme}://${value.replace(/^\/\//, "")}`;
157+
158+
const url = parseUrlString(candidate);
147159
if (!url) {
148160
throw new ConfigLoadError(
149161
`Invalid config value for ${fieldName}: expected a valid URL, received ${
@@ -153,30 +165,62 @@ const assertExplicitUrl = (
153165
);
154166
}
155167
if (
156-
!allowedSchemes ||
157-
allowedSchemes.includes(url.protocol.slice(0, -1))
168+
!options.allowedSchemes ||
169+
options.allowedSchemes.includes(url.protocol.slice(0, -1))
158170
) {
159-
return;
171+
if (!hasExplicitScheme && options.defaultPort && !url.port) {
172+
url.port = options.defaultPort;
173+
}
174+
return url.toString();
160175
}
176+
161177
throw new ConfigLoadError(
162178
`Invalid config value for ${fieldName}: expected URL scheme ${
163-
allowedSchemes.map((scheme) => JSON.stringify(scheme)).join(" or ")
179+
options.allowedSchemes.map((scheme) => JSON.stringify(scheme)).join(
180+
" or ",
181+
)
164182
}, received ${JSON.stringify(redactEndpointUserInfo(value))}`,
165183
{ code: "config-invalid" },
166184
);
167185
};
168186

169-
const validateExplicitConfig = (value: RawGraphitiConfig | null): void => {
170-
if (!value) return;
171-
assertExplicitUrl(value.endpoint, "endpoint", ["http", "https"]);
172-
assertExplicitUrl(value.graphiti?.endpoint, "graphiti.endpoint", [
173-
"http",
174-
"https",
175-
]);
176-
assertExplicitUrl(value.redis?.endpoint, "redis.endpoint", [
177-
"redis",
178-
"rediss",
179-
]);
187+
const normalizeConfiguredEndpoints = (
188+
value: RawGraphitiConfig | null,
189+
): RawGraphitiConfig | null => {
190+
if (!value) return value;
191+
192+
return {
193+
...value,
194+
endpoint: coerceConfiguredUrl(value.endpoint, "endpoint", {
195+
allowedSchemes: ["http", "https"],
196+
defaultScheme: "http",
197+
defaultPort: "8000",
198+
}),
199+
graphiti: value.graphiti
200+
? {
201+
...value.graphiti,
202+
endpoint: coerceConfiguredUrl(
203+
value.graphiti.endpoint,
204+
"graphiti.endpoint",
205+
{
206+
allowedSchemes: ["http", "https"],
207+
defaultScheme: "http",
208+
defaultPort: "8000",
209+
},
210+
),
211+
}
212+
: value.graphiti,
213+
redis: value.redis
214+
? {
215+
...value.redis,
216+
endpoint: coerceConfiguredUrl(value.redis.endpoint, "redis.endpoint", {
217+
allowedSchemes: ["redis", "rediss"],
218+
defaultScheme: "redis",
219+
defaultPort: "6379",
220+
}),
221+
}
222+
: value.redis,
223+
};
180224
};
181225

182226
const resolveNumber = (
@@ -288,8 +332,9 @@ const loadConfigFile = (
288332
): RawGraphitiConfig | null => {
289333
try {
290334
const loaded = adapter?.load(filePath);
291-
const normalized = loaded ? normalizeConfig(loaded.config) : null;
292-
validateExplicitConfig(normalized);
335+
const normalized = loaded
336+
? normalizeConfiguredEndpoints(normalizeConfig(loaded.config))
337+
: null;
293338
return normalized;
294339
} catch (err) {
295340
if (err instanceof ConfigLoadError) throw err;
@@ -314,8 +359,9 @@ const searchConfig = (
314359
): RawGraphitiConfig | null => {
315360
try {
316361
const loaded = adapter.search(directory);
317-
const normalized = loaded ? normalizeConfig(loaded.config) : null;
318-
validateExplicitConfig(normalized);
362+
const normalized = loaded
363+
? normalizeConfiguredEndpoints(normalizeConfig(loaded.config))
364+
: null;
319365
return normalized;
320366
} catch (err) {
321367
if (err instanceof ConfigLoadError) throw err;
@@ -349,7 +395,6 @@ export function loadConfig(directory?: string): GraphitiConfig {
349395
const adapter = getConfigExplorerAdapter();
350396
const loaded = searchConfig(adapter, directory);
351397
const resolved = loaded ?? loadLegacyConfig(adapter);
352-
validateExplicitConfig(resolved);
353398
return resolveConfig(resolved);
354399
} catch (error) {
355400
if (

0 commit comments

Comments
 (0)