Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 30 additions & 5 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -433,8 +433,8 @@ function agentCard(baseUrl: string) {
},
// So HTTP-only agents can call the API directly without installing the MCP
apiEndpoints: {
search: { method: 'GET', path: '/rag/search', params: ['q', 'limit', 'hasRemote', 'reachable', 'citations', 'localOnly', 'minScore', 'categories', 'serverKind'] },
top: { method: 'GET', path: '/rag/top', params: ['limit', 'minScore', 'hasRemote', 'reachable', 'localOnly', 'categories', 'serverKind'] },
search: { method: 'GET', path: '/rag/search', params: ['q', 'limit', 'hasRemote', 'reachable', 'reachableMaxAgeHours', 'citations', 'localOnly', 'minScore', 'categories', 'serverKind'] },
top: { method: 'GET', path: '/rag/top', params: ['limit', 'minScore', 'hasRemote', 'reachable', 'reachableMaxAgeHours', 'localOnly', 'categories', 'serverKind'] },
install: { method: 'GET', path: '/rag/install', params: ['name'] },
stats: { method: 'GET', path: '/rag/stats', params: [] },
listServers: { method: 'GET', path: '/v0.1/servers', params: ['limit', 'cursor'] },
Expand Down Expand Up @@ -471,6 +471,7 @@ const RagSearchQuerySchema = z.object({
registryType: z.string().optional(),
hasRemote: z.enum(['true', 'false']).optional(),
reachable: z.enum(['true', 'false']).optional(),
reachableMaxAgeHours: z.coerce.number().int().min(1).max(8760).optional(),
citations: z.enum(['true', 'false']).optional(),
localOnly: z.enum(['true', 'false']).optional(),
serverKind: z.enum(['retriever', 'evaluator', 'indexer', 'router', 'other']).optional()
Expand All @@ -482,6 +483,7 @@ const RagTopQuerySchema = z.object({
minScore: z.coerce.number().int().min(0).max(100).optional(),
hasRemote: z.enum(['true', 'false']).optional(),
reachable: z.enum(['true', 'false']).optional(),
reachableMaxAgeHours: z.coerce.number().int().min(1).max(8760).optional(),
localOnly: z.enum(['true', 'false']).optional(),
serverKind: z.enum(['retriever', 'evaluator', 'indexer', 'router', 'other']).optional()
});
Expand Down Expand Up @@ -1228,6 +1230,7 @@ export async function buildApp(params: { env: Env; store: RegistryStore }) {
const registryType = (query.registryType ?? '').trim() || undefined;
const hasRemote = parseOptionalBool(query.hasRemote);
const reachable = parseOptionalBool(query.reachable);
const reachableMaxAgeHours = reachable === true ? query.reachableMaxAgeHours : undefined;
const citations = parseOptionalBool(query.citations);
const localOnly = parseOptionalBool(query.localOnly);
const filters: RagFilters = RagFiltersSchema.parse({
Expand All @@ -1237,6 +1240,7 @@ export async function buildApp(params: { env: Env; store: RegistryStore }) {
registryType,
hasRemote,
reachable,
reachableMaxAgeHours,
citations,
localOnly,
serverKind: query.serverKind
Expand All @@ -1255,7 +1259,16 @@ export async function buildApp(params: { env: Env; store: RegistryStore }) {
return {
query: q,
results: results.map(mapRagHit),
metadata: { count: results.length }
metadata: {
count: results.length,
...(reachableMaxAgeHours != null
? {
filters: {
reachableMaxAgeHours
}
}
: {})
}
};
});

Expand All @@ -1264,19 +1277,31 @@ export async function buildApp(params: { env: Env; store: RegistryStore }) {
if (!query) return;

const limit = query.limit ?? 25;
const reachable = parseOptionalBool(query.reachable);
const reachableMaxAgeHours = reachable === true ? query.reachableMaxAgeHours : undefined;
const filters: RagFilters = RagFiltersSchema.parse({
categories: parseCategories(query.categories),
minScore: query.minScore ?? 10,
hasRemote: parseOptionalBool(query.hasRemote),
reachable: parseOptionalBool(query.reachable),
reachable,
reachableMaxAgeHours,
localOnly: parseOptionalBool(query.localOnly),
serverKind: query.serverKind ?? 'retriever'
});

const results = await params.store.searchRagTop({ limit, filters });
return {
results: results.map(mapRagHit),
metadata: { count: results.length }
metadata: {
count: results.length,
...(reachableMaxAgeHours != null
? {
filters: {
reachableMaxAgeHours
}
}
: {})
}
};
});

Expand Down
9 changes: 9 additions & 0 deletions apps/api/src/rag/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,15 @@ function passesFilters(item: RagSearchItem, filters: RagFilters | undefined) {
if (filters.reachable === true) {
if (enrichment?.reachable !== true) return false;
}
if (filters.reachable === true && filters.reachableMaxAgeHours != null) {
const checkedAtRaw = (enrichment as any)?.reachableCheckedAt;
if (typeof checkedAtRaw !== 'string' || !checkedAtRaw) return false;
Comment on lines +198 to +199

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Fall back to lastReachableAt for freshness filtering

In passesFilters, the new freshness gate only reads enrichment.reachableCheckedAt, so records that still store reachability time under lastReachableAt are treated as missing and excluded whenever reachable=true&reachableMaxAgeHours=... is used. The codebase still treats lastReachableAt as a valid fallback elsewhere (for example, response mapping in apps/api/src/app.ts), so this creates false negatives for older-but-valid indexed data until those rows are rewritten.

Useful? React with 👍 / 👎.

const checkedAtMs = Date.parse(checkedAtRaw);
if (!Number.isFinite(checkedAtMs)) return false;
const maxAgeMs = filters.reachableMaxAgeHours * 3_600_000;
const ageMs = Date.now() - checkedAtMs;
if (ageMs > maxAgeMs) return false;
}
if (filters.citations === true) {
if (enrichment?.citations !== true) return false;
}
Expand Down
185 changes: 185 additions & 0 deletions apps/api/tests/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -752,6 +752,105 @@ test('rag search exposes reachability metadata fields', async () => {
await app.close();
});

test('rag search reachableMaxAgeHours keeps only fresh reachable results and leaves reachable=true unchanged when omitted', async () => {
const store = new InMemoryStore();
const nowMs = Date.now();
const freshCheckedAt = new Date(nowMs - 1 * 3_600_000).toISOString();
const staleCheckedAt = new Date(nowMs - 30 * 3_600_000).toISOString();

await store.upsertServerVersion({
runId: 'run_test',
at: new Date(),
server: {
name: 'example/freshness-fresh',
version: '1.0.0',
description: 'freshness retriever',
remotes: [{ type: 'streamable-http', url: 'https://example.com/fresh' }]
},
official: { isLatest: true, updatedAt: new Date().toISOString(), publishedAt: new Date().toISOString() },
ragmap: {
categories: ['rag'],
ragScore: 80,
reasons: ['test'],
keywords: ['freshness'],
hasRemote: true,
reachable: true,
reachableCheckedAt: freshCheckedAt,
serverKind: 'retriever'
},
hidden: false
});
await store.upsertServerVersion({
runId: 'run_test',
at: new Date(),
server: {
name: 'example/freshness-stale',
version: '1.0.0',
description: 'freshness retriever',
remotes: [{ type: 'streamable-http', url: 'https://example.com/stale' }]
},
official: { isLatest: true, updatedAt: new Date().toISOString(), publishedAt: new Date().toISOString() },
ragmap: {
categories: ['rag'],
ragScore: 70,
reasons: ['test'],
keywords: ['freshness'],
hasRemote: true,
reachable: true,
reachableCheckedAt: staleCheckedAt,
serverKind: 'retriever'
},
hidden: false
});
await store.upsertServerVersion({
runId: 'run_test',
at: new Date(),
server: {
name: 'example/freshness-missing-checked-at',
version: '1.0.0',
description: 'freshness retriever',
remotes: [{ type: 'streamable-http', url: 'https://example.com/missing' }]
},
official: { isLatest: true, updatedAt: new Date().toISOString(), publishedAt: new Date().toISOString() },
ragmap: {
categories: ['rag'],
ragScore: 60,
reasons: ['test'],
keywords: ['freshness'],
hasRemote: true,
reachable: true,
serverKind: 'retriever'
},
hidden: false
});

const app = await buildApp({ env, store });
const withFreshness = await app.inject({
method: 'GET',
url: '/rag/search?q=freshness&hasRemote=true&reachable=true&reachableMaxAgeHours=24&limit=50'
});
assert.equal(withFreshness.statusCode, 200);
const withFreshnessBody = withFreshness.json() as any;
assert.equal(withFreshnessBody.metadata.count, 1);
assert.equal(withFreshnessBody.metadata.filters.reachableMaxAgeHours, 24);
assert.equal(withFreshnessBody.results[0].name, 'example/freshness-fresh');

const withoutFreshness = await app.inject({
method: 'GET',
url: '/rag/search?q=freshness&hasRemote=true&reachable=true&limit=50'
});
assert.equal(withoutFreshness.statusCode, 200);
const withoutFreshnessBody = withoutFreshness.json() as any;
const names = withoutFreshnessBody.results.map((result: any) => result.name);
assert.equal(withoutFreshnessBody.metadata.count, 3);
assert.equal(names.includes('example/freshness-fresh'), true);
assert.equal(names.includes('example/freshness-stale'), true);
assert.equal(names.includes('example/freshness-missing-checked-at'), true);
assert.equal(withoutFreshnessBody.metadata.filters, undefined);

await app.close();
});

test('rag top returns non-empty recommended retrievers with default filters', async () => {
const store = new InMemoryStore();
await store.upsertServerVersion({
Expand All @@ -778,6 +877,92 @@ test('rag top returns non-empty recommended retrievers with default filters', as
await app.close();
});

test('rag top applies reachableMaxAgeHours when reachable=true and echoes the filter', async () => {
const store = new InMemoryStore();
const nowMs = Date.now();
const freshCheckedAt = new Date(nowMs - 1 * 3_600_000).toISOString();
const staleCheckedAt = new Date(nowMs - 30 * 3_600_000).toISOString();

await store.upsertServerVersion({
runId: 'run_test',
at: new Date(),
server: {
name: 'example/top-fresh',
version: '0.1.0',
description: 'retrieval semantic search rag server',
remotes: [{ type: 'streamable-http', url: 'https://example.com/top-fresh' }]
},
official: { isLatest: true, updatedAt: new Date().toISOString(), publishedAt: new Date().toISOString() },
ragmap: {
categories: ['rag'],
ragScore: 90,
reasons: ['test'],
keywords: ['top'],
serverKind: 'retriever',
hasRemote: true,
reachable: true,
reachableCheckedAt: freshCheckedAt
},
hidden: false
});
await store.upsertServerVersion({
runId: 'run_test',
at: new Date(),
server: {
name: 'example/top-stale',
version: '0.1.0',
description: 'retrieval semantic search rag server',
remotes: [{ type: 'streamable-http', url: 'https://example.com/top-stale' }]
},
official: { isLatest: true, updatedAt: new Date().toISOString(), publishedAt: new Date().toISOString() },
ragmap: {
categories: ['rag'],
ragScore: 80,
reasons: ['test'],
keywords: ['top'],
serverKind: 'retriever',
hasRemote: true,
reachable: true,
reachableCheckedAt: staleCheckedAt
},
hidden: false
});
await store.upsertServerVersion({
runId: 'run_test',
at: new Date(),
server: {
name: 'example/top-missing-checked-at',
version: '0.1.0',
description: 'retrieval semantic search rag server',
remotes: [{ type: 'streamable-http', url: 'https://example.com/top-missing' }]
},
official: { isLatest: true, updatedAt: new Date().toISOString(), publishedAt: new Date().toISOString() },
ragmap: {
categories: ['rag'],
ragScore: 70,
reasons: ['test'],
keywords: ['top'],
serverKind: 'retriever',
hasRemote: true,
reachable: true
},
hidden: false
});

const app = await buildApp({ env, store });
const withFreshness = await app.inject({
method: 'GET',
url: '/rag/top?reachable=true&reachableMaxAgeHours=24&minScore=0&serverKind=retriever&limit=50'
});
assert.equal(withFreshness.statusCode, 200);
const withFreshnessBody = withFreshness.json() as any;
assert.equal(withFreshnessBody.metadata.count, 1);
assert.equal(withFreshnessBody.metadata.filters.reachableMaxAgeHours, 24);
assert.equal(withFreshnessBody.results[0].name, 'example/top-fresh');

await app.close();
});

test('rag install returns copy-ready config object', async () => {
const store = new InMemoryStore();
await store.upsertServerVersion({
Expand Down
12 changes: 10 additions & 2 deletions apps/mcp-remote/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,13 +61,14 @@ function registerTools(server: McpServer) {
registryType: z.string().min(1).optional(),
hasRemote: z.boolean().optional(),
reachable: z.boolean().optional(),
reachableMaxAgeHours: z.number().int().min(1).max(8760).optional(),
citations: z.boolean().optional(),
localOnly: z.boolean().optional(),
serverKind: z.enum(['retriever', 'evaluator', 'indexer', 'router', 'other']).optional(),
limit: z.number().int().min(1).max(50).optional()
}
},
async ({ query, categories, minScore, transport, registryType, hasRemote, reachable, citations, localOnly, serverKind, limit }) => {
async ({ query, categories, minScore, transport, registryType, hasRemote, reachable, reachableMaxAgeHours, citations, localOnly, serverKind, limit }) => {
const response = await apiGet('/rag/search', {
q: query ?? 'rag',
limit: String(limit ?? 10),
Expand All @@ -77,6 +78,9 @@ function registerTools(server: McpServer) {
...(registryType ? { registryType } : {}),
...(hasRemote !== undefined ? { hasRemote: String(hasRemote) } : {}),
...(reachable !== undefined ? { reachable: String(reachable) } : {}),
...(reachable === true && reachableMaxAgeHours != null
? { reachableMaxAgeHours: String(reachableMaxAgeHours) }
: {}),
...(citations !== undefined ? { citations: String(citations) } : {}),
...(localOnly !== undefined ? { localOnly: String(localOnly) } : {}),
...(serverKind ? { serverKind } : {})
Expand All @@ -99,18 +103,22 @@ function registerTools(server: McpServer) {
minScore: z.number().int().min(0).max(100).optional(),
hasRemote: z.boolean().optional(),
reachable: z.boolean().optional(),
reachableMaxAgeHours: z.number().int().min(1).max(8760).optional(),
localOnly: z.boolean().optional(),
serverKind: z.enum(['retriever', 'evaluator', 'indexer', 'router', 'other']).optional(),
limit: z.number().int().min(1).max(50).optional()
}
},
async ({ categories, minScore, hasRemote, reachable, localOnly, serverKind, limit }) => {
async ({ categories, minScore, hasRemote, reachable, reachableMaxAgeHours, localOnly, serverKind, limit }) => {
const response = await apiGet('/rag/top', {
limit: String(limit ?? 25),
...(categories && categories.length ? { categories: categories.join(',') } : {}),
...(minScore != null ? { minScore: String(minScore) } : {}),
...(hasRemote !== undefined ? { hasRemote: String(hasRemote) } : {}),
...(reachable !== undefined ? { reachable: String(reachable) } : {}),
...(reachable === true && reachableMaxAgeHours != null
? { reachableMaxAgeHours: String(reachableMaxAgeHours) }
: {}),
...(localOnly !== undefined ? { localOnly: String(localOnly) } : {}),
...(serverKind ? { serverKind } : {})
});
Expand Down
4 changes: 4 additions & 0 deletions docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,10 @@ For repository workflows that call `/internal/*` routes:
- Reachability probes now cover both `streamable-http` and `sse` remotes:
- `streamable-http`: `HEAD` probe, with `GET` fallback.
- `sse`: short `GET` with `Accept: text/event-stream`, then immediate body cancel so checks do not hang on streaming responses.
- Search freshness filter:
- `/rag/search` and `/rag/top` support `reachableMaxAgeHours`.
- `reachable=true&reachableMaxAgeHours=24` means "reachable and checked within the last 24 hours."
- When `reachableMaxAgeHours` is set, entries missing `reachableCheckedAt` are excluded.
- `/rag/install` now emits remote configs for both `streamable-http` and `sse` endpoints.
Note: SSE support depends on the MCP host/client; Ragmap only emits the correct transport config.
- `/rag/install` also emits `claudeDesktopNote` so UIs can clarify that Claude Desktop remote MCP servers may need to be added via the Connectors UI.
Expand Down
Loading