Skip to content
Open
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
90 changes: 90 additions & 0 deletions src/lib/discover.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,3 +189,93 @@ describe("discover MCP onboarding overrides", () => {
expect(setup).not.toContain("api.slack.com/apps?new_app=1");
});
});

describe("discover owner declaration merge", () => {
const declaredSource = "https://example.com/.well-known/integrations.json";

function httpDiscovery(recorded: Record<string, unknown>): ChatFn {
return async () => ({
message: { role: "assistant", content: null },
toolCalls: [
{
id: "cred-1",
name: "record_credential",
arguments: { id: "api_key", type: "api_key", label: "API key", setup: "Create a key in the dashboard." },
},
{
id: "surface-1",
name: "record_surface",
arguments: {
name: "Example API",
type: "http",
authStatus: "required",
auth: [{ use: [{ id: "api_key", mechanics: { source: "http", in: "header", headerName: "Authorization", scheme: "Bearer" } }], basis: { via: "discovered", evidence: ["https://example.com/docs/api"] } }],
basis: { via: "discovered", evidence: ["https://example.com/docs/api"] },
...recorded,
},
},
{ id: "finish-1", name: "finish", arguments: { summary: "Provides an HTTP API.", description: "Example service." } },
],
});
}

function declaredDetection(declaredSurface: Record<string, unknown>): DetectionResult {
return {
domain: "example.com",
found: [PROBE_KEYS.integrationsJson],
probed: [PROBE_KEYS.integrationsJson],
mcp: [],
errors: [],
integrationsJson: {
url: declaredSource,
result: {
version: 3,
credentials: { api_key: { type: "api_key", label: "API key", setup: "Create a key in the dashboard." } },
surfaces: [
{
slug: "api",
name: "Example API",
type: "http",
basis: { via: "declared", source: declaredSource },
auth: { status: "required", entries: [{ use: [{ id: "api_key", mechanics: { source: "http", in: "header", headerName: "Authorization", scheme: "Bearer" } }], basis: { via: "declared", source: declaredSource } }] },
...declaredSurface,
},
],
},
} as DetectionResult["integrationsJson"],
};
}

test("folds a declared surface with a spec into the discovered row that only knows the base URL", async () => {
const chat = httpDiscovery({ url: "https://api.example.com/v1" });
const result = await discover("example.com", declaredDetection({ url: "https://api.example.com/v1", spec: "https://api.example.com/v1/openapi.json" }), chat, web);
if (!result) throw new Error("discover returned null");

const http = result.surfaces.filter((s) => s.type === "http");
expect(http).toHaveLength(1);
expect(http[0]?.url).toBe("https://api.example.com/v1");
expect(http[0]?.spec).toBe("https://api.example.com/v1/openapi.json");
expect(http[0]?.basis.via).toBe("declared");
});

test("folds a declared base-URL-only surface into a discovered row that knows the spec", async () => {
// A graphql "introspection" spec is a literal, not a URL, so record_surface
// keeps it without the network validation an OpenAPI URL would trigger.
const chat = httpDiscovery({ name: "Example GraphQL", type: "graphql", url: "https://api.example.com/graphql", spec: "introspection" });
const result = await discover("example.com", declaredDetection({ name: "Example GraphQL", type: "graphql", url: "https://api.example.com/graphql" }), chat, web);
if (!result) throw new Error("discover returned null");

const graphql = result.surfaces.filter((s) => s.type === "graphql");
expect(graphql).toHaveLength(1);
expect(graphql[0]?.spec).toBe("introspection");
expect(graphql[0]?.basis.via).toBe("declared");
});

test("still adds a declared surface for a different base URL", async () => {
const chat = httpDiscovery({ url: "https://api.example.com/v1" });
const result = await discover("example.com", declaredDetection({ name: "Ingest API", url: "https://ingest.example.com", slug: "ingest-api" }), chat, web);
if (!result) throw new Error("discover returned null");

expect(result.surfaces.filter((s) => s.type === "http")).toHaveLength(2);
});
});
26 changes: 25 additions & 1 deletion src/lib/discover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -741,6 +741,14 @@ function surfaceLocator(s: Surface): string {
return `${s.type}|${(s.spec || s.url || s.command || packageId || s.name).toLowerCase()}`;
}

/** Secondary identity for surfaces that carry a base URL. The primary locator
* prefers `spec`, so an owner declaration that names both `url` and `spec`
* keys differently from a discovered surface that only knows `url` (or the
* reverse) even though they describe the same API. */
function surfaceUrlKey(s: Surface): string | undefined {
return s.url ? `${s.type}|${s.url.toLowerCase()}` : undefined;
}

function entryKey(entry: AuthEntry): string {
return entry.use
.map((use) => `${use.id}:${JSON.stringify(use.mechanics)}`)
Expand Down Expand Up @@ -813,10 +821,25 @@ function mergeDeclared(r: DiscoveryResult, detect: DetectionResult, emit?: Emit)
}
}
const byLocator = new Map(r.surfaces.map((surface) => [surfaceLocator(surface), surface]));
// Fallback identity by base URL, so a declaration is folded into the row
// for the same endpoint even when only one side knows the spec URL. Without
// it the page shows the API twice: once "discovered", once "declared".
const byUrl = new Map<string, Surface>();
const indexUrl = (surface: Surface): void => {
const key = surfaceUrlKey(surface);
if (key && !byUrl.has(key)) byUrl.set(key, surface);
};
for (const surface of r.surfaces) indexUrl(surface);
const findExisting = (surface: Surface): Surface | undefined => {
const byLoc = byLocator.get(surfaceLocator(surface));
if (byLoc) return byLoc;
const key = surfaceUrlKey(surface);
return key ? byUrl.get(key) : undefined;
};
for (const rawSurface of declared.result.surfaces ?? []) {
const surface = markDeclaredSurface(cloneJson(rawSurface) as Surface, source);
if (!surface.slug) surface.slug = assignSlug(surface.name || "Declared surface", r.surfaces);
const existing = byLocator.get(surfaceLocator(surface));
const existing = findExisting(surface);
if (existing) {
mergeDeclaredSurface(existing, surface);
emit?.({ kind: "surface", surface: existing });
Expand All @@ -825,6 +848,7 @@ function mergeDeclared(r: DiscoveryResult, detect: DetectionResult, emit?: Emit)
surface.slug = assignSlug(surface.slug || surface.name || "Declared surface", r.surfaces);
r.surfaces.push(surface);
byLocator.set(surfaceLocator(surface), surface);
indexUrl(surface);
emit?.({ kind: "surface", surface });
}
}
Expand Down