Merge branch 'main' into issue-17132-env-compat-warning

This commit is contained in:
Spencer
2026-02-20 13:10:46 -05:00
committed by GitHub
24 changed files with 1567 additions and 19 deletions
+4 -1
View File
@@ -77,7 +77,10 @@ export async function checkPolicy(
}
}
return { decision, rule: result.rule };
return {
decision,
rule: result.rule,
};
}
/**
@@ -192,6 +192,8 @@ export class ToolExecutor {
tool: call.tool,
invocation: call.invocation,
durationMs: startTime ? Date.now() - startTime : undefined,
startTime,
endTime: Date.now(),
outcome: call.outcome,
};
}
@@ -263,6 +265,8 @@ export class ToolExecutor {
response: successResponse,
invocation: call.invocation,
durationMs: startTime ? Date.now() - startTime : undefined,
startTime,
endTime: Date.now(),
outcome: call.outcome,
};
}
@@ -287,6 +291,8 @@ export class ToolExecutor {
response,
tool: call.tool,
durationMs: startTime ? Date.now() - startTime : undefined,
startTime,
endTime: Date.now(),
outcome: call.outcome,
};
}
+6
View File
@@ -86,6 +86,8 @@ export type ErroredToolCall = {
response: ToolCallResponseInfo;
tool?: AnyDeclarativeTool;
durationMs?: number;
startTime?: number;
endTime?: number;
outcome?: ToolConfirmationOutcome;
schedulerId?: string;
approvalMode?: ApprovalMode;
@@ -98,6 +100,8 @@ export type SuccessfulToolCall = {
response: ToolCallResponseInfo;
invocation: AnyToolInvocation;
durationMs?: number;
startTime?: number;
endTime?: number;
outcome?: ToolConfirmationOutcome;
schedulerId?: string;
approvalMode?: ApprovalMode;
@@ -125,6 +129,8 @@ export type CancelledToolCall = {
tool: AnyDeclarativeTool;
invocation: AnyToolInvocation;
durationMs?: number;
startTime?: number;
endTime?: number;
outcome?: ToolConfirmationOutcome;
schedulerId?: string;
approvalMode?: ApprovalMode;
+12
View File
@@ -243,6 +243,8 @@ export class ToolCallEvent implements BaseTelemetryEvent {
mcp_server_name?: string;
extension_name?: string;
extension_id?: string;
start_time?: number;
end_time?: number;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
metadata?: { [key: string]: any };
@@ -256,6 +258,8 @@ export class ToolCallEvent implements BaseTelemetryEvent {
prompt_id: string,
tool_type: 'native' | 'mcp',
error?: string,
start_time?: number,
end_time?: number,
);
constructor(
call?: CompletedToolCall,
@@ -266,6 +270,8 @@ export class ToolCallEvent implements BaseTelemetryEvent {
prompt_id?: string,
tool_type?: 'native' | 'mcp',
error?: string,
start_time?: number,
end_time?: number,
) {
this['event.name'] = 'tool_call';
this['event.timestamp'] = new Date().toISOString();
@@ -282,6 +288,8 @@ export class ToolCallEvent implements BaseTelemetryEvent {
this.error_type = call.response.errorType;
this.prompt_id = call.request.prompt_id;
this.content_length = call.response.contentLength;
this.start_time = call.startTime;
this.end_time = call.endTime;
if (
typeof call.tool !== 'undefined' &&
call.tool instanceof DiscoveredMCPTool
@@ -332,6 +340,8 @@ export class ToolCallEvent implements BaseTelemetryEvent {
this.prompt_id = prompt_id!;
this.tool_type = tool_type!;
this.error = error;
this.start_time = start_time;
this.end_time = end_time;
}
}
@@ -351,6 +361,8 @@ export class ToolCallEvent implements BaseTelemetryEvent {
mcp_server_name: this.mcp_server_name,
extension_name: this.extension_name,
extension_id: this.extension_id,
start_time: this.start_time,
end_time: this.end_time,
metadata: this.metadata,
};
@@ -1089,7 +1089,7 @@ exports[`coreTools snapshots for specific models > Model: gemini-3-pro-preview >
exports[`coreTools snapshots for specific models > Model: gemini-3-pro-preview > snapshot for tool: grep_search_ripgrep 1`] = `
{
"description": "Searches for a regular expression pattern within file contents.",
"description": "Searches for a regular expression pattern within file contents. This tool is FAST and optimized, powered by ripgrep. PREFERRED over standard \`run_shell_command("grep ...")\` due to better performance and automatic output limiting (defaults to 100 matches, but can be increased via \`total_max_matches\`).",
"name": "grep_search",
"parametersJsonSchema": {
"properties": {
@@ -131,7 +131,7 @@ The user has the ability to modify \`content\`. If modified, this will be stated
grep_search_ripgrep: {
name: GREP_TOOL_NAME,
description:
'Searches for a regular expression pattern within file contents.',
'Searches for a regular expression pattern within file contents. This tool is FAST and optimized, powered by ripgrep. PREFERRED over standard `run_shell_command("grep ...")` due to better performance and automatic output limiting (defaults to 100 matches, but can be increased via `total_max_matches`).',
parametersJsonSchema: {
type: 'object',
properties: {
@@ -2056,6 +2056,90 @@ describe('connectToMcpServer with OAuth', () => {
capturedTransport._requestInit?.headers?.['Authorization'];
expect(authHeader).toBe('Bearer test-access-token-from-discovery');
});
it('should use discoverOAuthFromWWWAuthenticate when it succeeds and skip discoverOAuthConfig', async () => {
const serverUrl = 'http://test-server.com/mcp';
const authUrl = 'http://auth.example.com/auth';
const tokenUrl = 'http://auth.example.com/token';
const wwwAuthHeader = `Bearer realm="test", resource_metadata="http://test-server.com/.well-known/oauth-protected-resource"`;
vi.mocked(mockedClient.connect).mockRejectedValueOnce(
new StreamableHTTPError(
401,
`Unauthorized\nwww-authenticate: ${wwwAuthHeader}`,
),
);
vi.mocked(OAuthUtils.discoverOAuthFromWWWAuthenticate).mockResolvedValue({
authorizationUrl: authUrl,
tokenUrl,
scopes: ['read'],
});
vi.mocked(mockedClient.connect).mockResolvedValueOnce(undefined);
const client = await connectToMcpServer(
'0.0.1',
'test-server',
{ httpUrl: serverUrl, oauth: { enabled: true } },
false,
workspaceContext,
EMPTY_CONFIG,
);
expect(client).toBe(mockedClient);
expect(OAuthUtils.discoverOAuthFromWWWAuthenticate).toHaveBeenCalledWith(
wwwAuthHeader,
serverUrl,
);
expect(OAuthUtils.discoverOAuthConfig).not.toHaveBeenCalled();
expect(mockAuthProvider.authenticate).toHaveBeenCalledOnce();
});
it('should fall back to extractBaseUrl + discoverOAuthConfig when discoverOAuthFromWWWAuthenticate returns null', async () => {
const serverUrl = 'http://test-server.com/mcp';
const baseUrl = 'http://test-server.com';
const authUrl = 'http://auth.example.com/auth';
const tokenUrl = 'http://auth.example.com/token';
const wwwAuthHeader = `Bearer realm="test"`;
vi.mocked(mockedClient.connect).mockRejectedValueOnce(
new StreamableHTTPError(
401,
`Unauthorized\nwww-authenticate: ${wwwAuthHeader}`,
),
);
vi.mocked(OAuthUtils.discoverOAuthFromWWWAuthenticate).mockResolvedValue(
null,
);
vi.mocked(OAuthUtils.extractBaseUrl).mockReturnValue(baseUrl);
vi.mocked(OAuthUtils.discoverOAuthConfig).mockResolvedValue({
authorizationUrl: authUrl,
tokenUrl,
scopes: ['read'],
});
vi.mocked(mockedClient.connect).mockResolvedValueOnce(undefined);
const client = await connectToMcpServer(
'0.0.1',
'test-server',
{ httpUrl: serverUrl, oauth: { enabled: true } },
false,
workspaceContext,
EMPTY_CONFIG,
);
expect(client).toBe(mockedClient);
expect(OAuthUtils.discoverOAuthFromWWWAuthenticate).toHaveBeenCalledWith(
wwwAuthHeader,
serverUrl,
);
expect(OAuthUtils.extractBaseUrl).toHaveBeenCalledWith(serverUrl);
expect(OAuthUtils.discoverOAuthConfig).toHaveBeenCalledWith(baseUrl);
expect(mockAuthProvider.authenticate).toHaveBeenCalledOnce();
});
});
describe('connectToMcpServer - HTTP→SSE fallback', () => {
+10 -13
View File
@@ -719,18 +719,17 @@ async function handleAutomaticOAuth(
try {
debugLogger.log(`🔐 '${mcpServerName}' requires OAuth authentication`);
// Always try to parse the resource metadata URI from the www-authenticate header
let oauthConfig;
const resourceMetadataUri =
OAuthUtils.parseWWWAuthenticateHeader(wwwAuthenticate);
if (resourceMetadataUri) {
oauthConfig = await OAuthUtils.discoverOAuthConfig(resourceMetadataUri);
} else if (hasNetworkTransport(mcpServerConfig)) {
const serverUrl = mcpServerConfig.httpUrl || mcpServerConfig.url;
// Try to discover OAuth config from the WWW-Authenticate header first
let oauthConfig = await OAuthUtils.discoverOAuthFromWWWAuthenticate(
wwwAuthenticate,
serverUrl,
);
if (!oauthConfig && hasNetworkTransport(mcpServerConfig)) {
// Fallback: try to discover OAuth config from the base URL
const serverUrl = new URL(
mcpServerConfig.httpUrl || mcpServerConfig.url!,
);
const baseUrl = `${serverUrl.protocol}//${serverUrl.host}`;
const baseUrl = OAuthUtils.extractBaseUrl(serverUrl!);
oauthConfig = await OAuthUtils.discoverOAuthConfig(baseUrl);
}
@@ -754,8 +753,6 @@ async function handleAutomaticOAuth(
};
// Perform OAuth authentication
// Pass the server URL for proper discovery
const serverUrl = mcpServerConfig.httpUrl || mcpServerConfig.url;
debugLogger.log(
`Starting OAuth authentication for server '${mcpServerName}'...`,
);
+20
View File
@@ -183,6 +183,26 @@ describe('WebFetchTool', () => {
});
describe('execute', () => {
it('should return WEB_FETCH_PROCESSING_ERROR on rate limit exceeded', async () => {
vi.spyOn(fetchUtils, 'isPrivateIp').mockReturnValue(false);
mockGenerateContent.mockResolvedValue({
candidates: [{ content: { parts: [{ text: 'response' }] } }],
});
const tool = new WebFetchTool(mockConfig, bus);
const params = { prompt: 'fetch https://ratelimit.example.com' };
const invocation = tool.build(params);
// Execute 10 times to hit the limit
for (let i = 0; i < 10; i++) {
await invocation.execute(new AbortController().signal);
}
// The 11th time should fail due to rate limit
const result = await invocation.execute(new AbortController().signal);
expect(result.error?.type).toBe(ToolErrorType.WEB_FETCH_PROCESSING_ERROR);
expect(result.error?.message).toContain('Rate limit exceeded for host');
});
it('should return WEB_FETCH_FALLBACK_FAILED on fallback fetch failure', async () => {
vi.spyOn(fetchUtils, 'isPrivateIp').mockReturnValue(true);
vi.spyOn(fetchUtils, 'fetchWithTimeout').mockRejectedValue(
+53
View File
@@ -33,10 +33,46 @@ import { debugLogger } from '../utils/debugLogger.js';
import { retryWithBackoff } from '../utils/retry.js';
import { WEB_FETCH_DEFINITION } from './definitions/coreTools.js';
import { resolveToolDeclaration } from './definitions/resolver.js';
import { LRUCache } from 'mnemonist';
const URL_FETCH_TIMEOUT_MS = 10000;
const MAX_CONTENT_LENGTH = 100000;
// Rate limiting configuration
const RATE_LIMIT_WINDOW_MS = 60000; // 1 minute
const MAX_REQUESTS_PER_WINDOW = 10;
const hostRequestHistory = new LRUCache<string, number[]>(1000);
function checkRateLimit(url: string): {
allowed: boolean;
waitTimeMs?: number;
} {
try {
const hostname = new URL(url).hostname;
const now = Date.now();
const windowStart = now - RATE_LIMIT_WINDOW_MS;
let history = hostRequestHistory.get(hostname) || [];
// Clean up old timestamps
history = history.filter((timestamp) => timestamp > windowStart);
if (history.length >= MAX_REQUESTS_PER_WINDOW) {
// Calculate wait time based on the oldest timestamp in the current window
const oldestTimestamp = history[0];
const waitTimeMs = oldestTimestamp + RATE_LIMIT_WINDOW_MS - now;
hostRequestHistory.set(hostname, history); // Update cleaned history
return { allowed: false, waitTimeMs: Math.max(0, waitTimeMs) };
}
history.push(now);
hostRequestHistory.set(hostname, history);
return { allowed: true };
} catch (_e) {
// If URL parsing fails, we fallback to allowed (should be caught by parsePrompt anyway)
return { allowed: true };
}
}
/**
* Parses a prompt to extract valid URLs and identify malformed ones.
*/
@@ -258,6 +294,23 @@ ${textContent}
const userPrompt = this.params.prompt;
const { validUrls: urls } = parsePrompt(userPrompt);
const url = urls[0];
// Enforce rate limiting
const rateLimitResult = checkRateLimit(url);
if (!rateLimitResult.allowed) {
const waitTimeSecs = Math.ceil((rateLimitResult.waitTimeMs || 0) / 1000);
const errorMessage = `Rate limit exceeded for host. Please wait ${waitTimeSecs} seconds before trying again.`;
debugLogger.warn(`[WebFetchTool] Rate limit exceeded for ${url}`);
return {
llmContent: `Error: ${errorMessage}`,
returnDisplay: `Error: ${errorMessage}`,
error: {
message: errorMessage,
type: ToolErrorType.WEB_FETCH_PROCESSING_ERROR,
},
};
}
const isPrivate = isPrivateIp(url);
if (isPrivate) {