Skip to content
Draft
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
1 change: 1 addition & 0 deletions src/vs/base/common/product.ts
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,7 @@ export interface IDefaultChatAgent {
readonly tokenEntitlementUrl: string;
readonly mcpRegistryDataUrl: string;
readonly mcpConnectorsUrl?: string;
readonly agentFinderFeaturedFeedId?: string;
readonly managedSettingsUrl: string;

readonly chatQuotaExceededContext: string;
Expand Down
113 changes: 107 additions & 6 deletions src/vs/platform/agentFinder/common/agentFinderRestProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,15 @@ import { IMcpGalleryService } from '../../mcp/common/mcpManagement.js';
import { IRequestService, readBoundedResponse } from '../../request/common/request.js';

const endpoint = 'https://agentfinder.github.com/api/v1';
const feedEndpoint = 'https://agentfinder.github.com/internal/v1/feeds';
const requestTimeout = 30_000;
const maxResponseBytes = 5 * 1024 * 1024;
const defaultPageSize = 30;
const maxPageSize = 100;
const maxFeedEntries = 100;
const maxFeedIdLength = 4096;
const maxFeedNameCodePoints = 512;
const maxFeedIdentifierLength = 512;
const maxQueryLength = 4096;
const maxUnsupportedOnlyPages = 32;
const maxUriLength = 8192;
Expand All @@ -39,6 +44,11 @@ const mcpIconRequestTimeout = 5_000;

class AgentFinderError extends Error { }

export interface IAgentFinderResourceFeed {
readonly name: string;
readonly items: readonly ICustomizationMarketplaceEntry[];
}

export class AgentFinderRestProvider implements ICustomizationMarketplaceProvider {
readonly id = CustomizationMarketplaceSources.AgentFinderPublicFeed.id;

Expand Down Expand Up @@ -88,7 +98,7 @@ export class AgentFinderRestProvider implements ICustomizationMarketplaceProvide
followRedirects: 0,
callSite: 'agentFinder.query',
};
const response = await raceCancellationError(this.requestPage(request, cancellation.token), cancellation.token);
const response = await raceCancellationError(this.requestJson(request, cancellation.token, 'catalog'), cancellation.token);
const page = parsePage(response, pageSize, query ? { kind: 'search', pageToken } : { kind: 'browse', offset });
const items = page.items.filter(item => item.mediaType !== CustomizationMarketplaceMediaType.CursorPlugin);
if (items.length || !page.nextCursor) {
Expand Down Expand Up @@ -121,6 +131,56 @@ export class AgentFinderRestProvider implements ICustomizationMarketplaceProvide
}
}

async getFeed(feedId: string, authorization: string, token: CancellationToken): Promise<IAgentFinderResourceFeed> {
if (token.isCancellationRequested) {
throw new CancellationError();
}
if (!feedId || feedId.length > maxFeedIdLength || !feedId.trim() || !authorization) {
throw new AgentFinderError(localize('agentFinder.invalidFeaturedFeed', "The featured customization feed is not configured correctly."));
}

const store = new DisposableStore();
const cancellation = store.add(new CancellationTokenSource(token));
let timedOut = false;
const requestTimeoutDisposable = disposableTimeout(() => {
timedOut = true;
cancellation.cancel();
}, requestTimeout, store);

try {
const response = await raceCancellationError(this.requestJson({
url: `${feedEndpoint}/${encodeURIComponent(feedId)}`,
type: 'GET',
headers: { Accept: 'application/json', Authorization: `Bearer ${authorization}` },
disableCache: true,
timeout: requestTimeout,
followRedirects: 0,
callSite: 'agentFinder.featured',
}, cancellation.token, 'featured'), cancellation.token);
const feed = parseFeed(response, feedId);
requestTimeoutDisposable.dispose();
const page = await this.resolveMcpIcons({ items: feed.items }, token);
return {
name: feed.name,
items: page.items.filter(item => item.mediaType !== CustomizationMarketplaceMediaType.CursorPlugin),
};
} catch (error) {
if (token.isCancellationRequested) {
throw new CancellationError();
}
if (timedOut) {
throw new AgentFinderError(localize('agentFinder.featuredTimeout', "The featured customizations took too long to load. Try again."));
}
if (error instanceof AgentFinderError || isCancellationError(error)) {
throw error;
}
throw new AgentFinderError(localize('agentFinder.featuredUnavailable', "Unable to reach the featured customization feed. Check your connection and try again."));
} finally {
cancellation.cancel();
store.dispose();
}
}

private async resolveMcpIcons(page: ICustomizationMarketplaceSourcePage, token: CancellationToken): Promise<ICustomizationMarketplaceSourcePage> {
if (!page.items.some(item => !item.icon && item.installation?.kind === 'mcp' && item.externalUrl)) {
return page;
Expand Down Expand Up @@ -175,24 +235,35 @@ export class AgentFinderRestProvider implements ICustomizationMarketplaceProvide
}
}

private async requestPage(options: IRequestOptions, token: CancellationToken): Promise<unknown> {
private async requestJson(options: IRequestOptions, token: CancellationToken, operation: 'catalog' | 'featured'): Promise<unknown> {
const context = await this.requestService.request(options, token);
try {
if (token.isCancellationRequested) {
throw new CancellationError();
}
const status = context.res.statusCode;
if (operation === 'featured' && status === 401) {
throw new AgentFinderError(localize('agentFinder.featuredSignInRequired', "Sign in to view featured customizations."));
}
if (status === 429) {
throw new AgentFinderError(localize('agentFinder.rateLimited', "The customization catalog is receiving too many requests. Try again later."));
throw new AgentFinderError(operation === 'catalog'
? localize('agentFinder.rateLimited', "The customization catalog is receiving too many requests. Try again later.")
: localize('agentFinder.featuredRateLimited', "The featured customization feed is receiving too many requests. Try again later."));
}
if (!status || status < 200 || status >= 300) {
throw new AgentFinderError(localize('agentFinder.httpError', "The customization catalog could not complete the request (HTTP {0}). Try again later.", status ?? '—'));
throw new AgentFinderError(operation === 'catalog'
? localize('agentFinder.httpError', "The customization catalog could not complete the request (HTTP {0}). Try again later.", status ?? '—')
: localize('agentFinder.featuredHttpError', "The featured customization feed could not complete the request (HTTP {0}). Try again later.", status ?? '—'));
}
const text = await raceCancellationError(readBoundedResponse(context, maxResponseBytes, () => new AgentFinderError(localize('agentFinder.responseTooLarge', "The customization catalog response is too large. Try a smaller page."))), token);
const text = await raceCancellationError(readBoundedResponse(context, maxResponseBytes, () => new AgentFinderError(operation === 'catalog'
? localize('agentFinder.responseTooLarge', "The customization catalog response is too large. Try a smaller page.")
: localize('agentFinder.featuredResponseTooLarge', "The featured customization feed response is too large."))), token);
try {
return JSON.parse(text);
} catch {
throw new AgentFinderError(localize('agentFinder.invalidJson', "The customization catalog returned invalid JSON. Try again later."));
throw new AgentFinderError(operation === 'catalog'
? localize('agentFinder.invalidJson', "The customization catalog returned invalid JSON. Try again later.")
: localize('agentFinder.featuredInvalidJson', "The featured customization feed returned invalid JSON. Try again later."));
}
} finally {
context.stream.destroy();
Expand Down Expand Up @@ -283,6 +354,36 @@ function parsePage(value: unknown, pageSize: number, cursor: { kind: 'browse'; o
};
}

function parseFeed(value: unknown, feedId: string): IAgentFinderResourceFeed {
if (!isRecord(value) || value.id !== feedId || typeof value.name !== 'string' || !value.name.trim() ||
[...value.name].length > maxFeedNameCodePoints || !isNonNegativeInteger(value.version) || value.version < 1 ||
!Array.isArray(value.entries) || value.entries.length > maxFeedEntries) {
throw invalidResponse();
}
const identifiers = new Set<string>();
const items: ICustomizationMarketplaceEntry[] = [];
for (const entry of value.entries) {
if (!isRecord(entry) || typeof entry.identifier !== 'string' || !entry.identifier ||
entry.identifier.length > maxFeedIdentifierLength || identifiers.has(entry.identifier) ||
(entry.status !== 'available' && entry.status !== 'unavailable')) {
throw invalidResponse();
}
identifiers.add(entry.identifier);
if (entry.status === 'unavailable') {
if (entry.resource !== undefined) {
throw invalidResponse();
}
continue;
}
const resource = parseResource(entry.resource);
if (resource.identifier !== entry.identifier) {
throw invalidResponse();
}
items.push(resource);
}
return { name: value.name, items };
}

function parseResource(value: unknown): ICustomizationMarketplaceEntry {
if (!isRecord(value)) {
throw invalidResponse();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,69 @@ suite('AgentFinderRestProvider', () => {
});
});

test('retrieves an authenticated saved feed in order and omits unavailable resources', async () => {
const { service, requests } = createService({
id: 'feed/featured',
name: 'Featured customizations',
version: 7,
entries: [
{ identifier: skill.identifier, status: 'available', resource: skill },
{ identifier: 'urn:air:example.com:skills:removed', status: 'unavailable' },
{
identifier: 'urn:air:github.com:github:awesome-copilot:plugins:accessibility-kanban',
status: 'available',
resource: {
...skill,
identifier: 'urn:air:github.com:github:awesome-copilot:plugins:accessibility-kanban',
displayName: 'Accessibility Kanban',
type: CustomizationMarketplaceMediaType.CopilotPlugin,
mediaType: CustomizationMarketplaceMediaType.CopilotPlugin,
url: 'https://github.com/github/awesome-copilot/blob/main/plugins/accessibility-kanban/plugin.json',
metadata: { sourceSet: 'github/awesome-copilot', repoPath: 'plugins/accessibility-kanban/plugin.json' },
},
},
],
});

const feed = await service.getFeed('feed/featured', 'featured-token', CancellationToken.None);

assert.deepStrictEqual({
name: feed.name,
items: feed.items.map(item => [item.identifier, item.displayName, item.installation]),
requests: requests.requests,
}, {
name: 'Featured customizations',
items: [
[skill.identifier, skill.displayName, { kind: 'skill', repository: 'ChromeDevTools/chrome-devtools-mcp', ref: 'main', path: 'skills/a11y-debugging' }],
['urn:air:github.com:github:awesome-copilot:plugins:accessibility-kanban', 'Accessibility Kanban', { kind: 'plugin', repository: 'github/awesome-copilot', ref: 'main', path: 'plugins/accessibility-kanban' }],
],
requests: [{
url: 'https://agentfinder.github.com/internal/v1/feeds/feed%2Ffeatured',
type: 'GET',
headers: { Accept: 'application/json', Authorization: 'Bearer featured-token' },
disableCache: true,
timeout: 30_000,
followRedirects: 0,
callSite: 'agentFinder.featured',
}],
});
});

test('rejects malformed saved feeds and reports authentication failures', async () => {
const invalidFeeds = [
{ id: 'different', name: 'Featured', version: 1, entries: [] },
{ id: 'feed', name: 'Featured', version: 1, entries: [{ identifier: skill.identifier, status: 'available', resource: { ...skill, identifier: 'different' } }] },
{ id: 'feed', name: 'Featured', version: 1, entries: [{ identifier: skill.identifier, status: 'unavailable', resource: skill }] },
];
for (const body of invalidFeeds) {
const { service } = createService(body);
await assert.rejects(service.getFeed('feed', 'token', CancellationToken.None), /invalid response/);
}

const { service } = createService({ error: 'Unauthorized' }, 401);
await assert.rejects(service.getFeed('feed', 'token', CancellationToken.None), /Sign in to view featured customizations/);
});

test('resolves an MCP registry icon through the fixed Agent Finder registry', async () => {
const lookups: { url: string; manifestUrl: string | undefined; manifestVersion: string | undefined; cancelled: boolean }[] = [];
const mcpGalleryService = upcastPartial<IMcpGalleryService>({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,12 @@ export interface ICustomizationMarketplacePage {
readonly sourceErrors?: readonly ICustomizationMarketplaceSourceError[];
}

export interface ICustomizationMarketplaceFeatured {
readonly sourceId: string;
readonly items: readonly ICustomizationMarketplaceResource[];
readonly error?: string;
}

export interface ICustomizationMarketplaceSourceError {
readonly sourceId: string;
readonly message: string;
Expand Down Expand Up @@ -209,7 +215,11 @@ export interface ICustomizationMarketplaceService {
readonly allSources?: readonly ICustomizationMarketplaceSourceInfo[];
/** Fires when non-configuration inputs change source availability or query identity. */
readonly onDidChangeSources?: Event<void>;
/** Sources that can provide an ordered browse-page collection. */
readonly featuredSourceIds?: readonly string[];
query(options: ICustomizationMarketplaceQuery, token: CancellationToken): Promise<ICustomizationMarketplacePage>;
/** Optional ordered collection for the browse page. Failures are returned on the collection so regular catalog results remain available. */
getFeatured?(options: Pick<ICustomizationMarketplaceQuery, 'sourceIds'>, token: CancellationToken): Promise<ICustomizationMarketplaceFeatured | undefined>;
/** Optional renderer-owned recovery; not part of the catalog transport. */
getSourceRecoveryAction?(sourceId: string): ICustomizationMarketplaceSourceRecoveryAction | undefined;
}
Expand Down
2 changes: 2 additions & 0 deletions src/vs/sessions/AI_CUSTOMIZATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ The home page shows the original Overview until the default-off marketplace visi

Marketplace composition supplies `ICustomizationMarketplaceProvider`s with unique provider IDs and user-visible source IDs. Multiple internal providers can belong to one contributed source; selecting that source queries all of its applicable providers while results and errors retain the contributed source ID. Per-source enablement and the query service remain independent for legacy management lists; only Discover presentation and Marketplace installation apply the separate visibility setting on top of enabled sources. The requesting window selects enabled source IDs and forwards them with each query; native desktop routes only the GitHub Feed through shared-process IPC and queries the MCP gallery in the renderer. Only selected providers are instantiated and queried. Each provider owns transport, response validation, metadata normalization, and validation of any installation provenance. Resource identity includes the source ID, opaque identifier, and version, including for deduplication and pending installation state. Optional resource icons accept either one theme-independent URI or a `{ light, dark }` URI bundle; consumers resolve bundles against the active VS Code theme. Installation records preserve the v1 scalar `icon` field as the light/default value and add an optional `iconDark` field, so existing scalar records and older readers remain compatible. Invalid persisted icon fields are sanitized and logged independently; presentation metadata never invalidates an otherwise valid installation record.

Marketplace services may advertise source IDs with an ordered featured collection. When the product supplies an AgentFinder featured-feed ID, Discover retrieves that collection with the current default GitHub account, preserves its live resource order, and keeps ordinary browse results available when the featured request fails. Products without a configured feed retain the local browse-order fallback and perform no featured-feed authentication or request.

A successful managed install creates an independently persisted, machine-local profile installation record containing the resource identity, its validated installation descriptor, and its exact local target or account-scoped connector identity. Independent storage keys prevent concurrent workbench windows from overwriting unrelated records. The install service exposes one observable immutable snapshot indexed by catalog identity and exact target; Skills, MCP, Discover, and management actions use its lookup helpers instead of rebuilding record indexes. Skill records also retain their harness/destination identity and relative package files; skill and repository-backed Plugin records retain the immutable Git revision that supplied their content. Configured Plugin records retain their opaque marketplace identity and exact installed URI. Connector records preserve that an account previously connected the resource when the current catalog no longer returns it, while the remote connection remains authoritative for current state. Local discovery never creates this association: same-name or same-repository local items remain independent until an explicit marketplace install succeeds.

Marketplace page size bounds the combined result list, not each source's contribution. Search sources return a descending sequence of optional 0–100 relevance scores across their native pages; a score is an adapter-assigned ranking signal, not a trust or quality rating. The service merges those sequences by score, treating absent scores as zero. Adapter-assigned entry priority breaks equal relevance scores and orders queryless browsing into tiers: custom entries precede defaults, and entries at the same tier interleave round-robin. Neither priority nor relevance is displayed as a quality or trust rating. The service preserves each source's native order and browse rotation across page boundaries. It queries sources concurrently, backfills short pages, preserves stable native fetch sizes, and buffers undisplayed entries without re-querying exhausted sources.
Expand Down
Loading
Loading