mirror of
https://github.com/microsoft/vscode.git
synced 2026-08-05 12:55:10 +01:00
Merge branch 'main' into eli/prompt-tile-style-clean
This commit is contained in:
@@ -133,3 +133,4 @@ function f(x: number, y: string): void { }
|
||||
- Look for existing test patterns before creating new structures
|
||||
- Use `describe` and `test` consistently with existing patterns
|
||||
- If you create any temporary new files, scripts, or helper files for iteration, clean up these files by removing them at the end of the task
|
||||
- Do not use `any` or `unknown` as the type for variables, parameters, or return values unless absolutely necessary. If they need type annotations, they should have proper types or interfaces defined.
|
||||
|
||||
@@ -402,7 +402,9 @@ function createTypeScriptBuilder(config, projectFile, cmd) {
|
||||
messageText: `CYCLIC dependency: ${error}`
|
||||
});
|
||||
}
|
||||
delete oldErrors[filename];
|
||||
newErrors[filename] = cyclicDepErrors;
|
||||
cyclicDepErrors.forEach(d => onError(d));
|
||||
}
|
||||
}).then(() => {
|
||||
// store the build versions to not rebuilt the next time
|
||||
|
||||
@@ -440,7 +440,9 @@ export function createTypeScriptBuilder(config: IConfiguration, projectFile: str
|
||||
messageText: `CYCLIC dependency: ${error}`
|
||||
});
|
||||
}
|
||||
delete oldErrors[filename];
|
||||
newErrors[filename] = cyclicDepErrors;
|
||||
cyclicDepErrors.forEach(d => onError(d));
|
||||
}
|
||||
|
||||
}).then(() => {
|
||||
|
||||
+2
-2
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "marmaid-chat-features",
|
||||
"name": "mermaid-chat-features",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "marmaid-chat-features",
|
||||
"name": "mermaid-chat-features",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
@@ -88,6 +88,8 @@
|
||||
"sideBarTitle.foreground": "#CCCCCC",
|
||||
"statusBar.background": "#181818",
|
||||
"statusBar.border": "#2B2B2B",
|
||||
"statusBarItem.hoverBackground": "#F1F1F133",
|
||||
"statusBarItem.hoverForeground": "#FFFFFF",
|
||||
"statusBar.debuggingBackground": "#0078D4",
|
||||
"statusBar.debuggingForeground": "#FFFFFF",
|
||||
"statusBar.focusBorder": "#0078D4",
|
||||
|
||||
@@ -104,7 +104,8 @@
|
||||
"statusBar.background": "#F8F8F8",
|
||||
"statusBar.foreground": "#3B3B3B",
|
||||
"statusBar.border": "#E5E5E5",
|
||||
"statusBarItem.hoverBackground": "#B8B8B850",
|
||||
"statusBarItem.hoverBackground": "#1F1F1F11",
|
||||
"statusBarItem.hoverForeground": "#000000",
|
||||
"statusBarItem.compactHoverBackground": "#CCCCCC",
|
||||
"statusBar.debuggingBackground": "#FD716C",
|
||||
"statusBar.debuggingForeground": "#000000",
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "code-oss-dev",
|
||||
"version": "1.105.0",
|
||||
"distro": "9e823daeded19d4a7c8f7e3ba36ca5d3c68d8350",
|
||||
"distro": "8b551ae26e4dadc3f6ece34bbbc3feea81ce4a97",
|
||||
"author": {
|
||||
"name": "Microsoft Corporation"
|
||||
},
|
||||
@@ -239,4 +239,4 @@
|
||||
"optionalDependencies": {
|
||||
"windows-foreground-love": "0.5.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -957,3 +957,120 @@ export function scopesMatch(scopes1: readonly string[], scopes2: readonly string
|
||||
|
||||
return sortedScopes1.every((scope, index) => scope === sortedScopes2[index]);
|
||||
}
|
||||
|
||||
interface CommonResponse {
|
||||
status: number;
|
||||
statusText: string;
|
||||
json(): Promise<any>;
|
||||
text(): Promise<string>;
|
||||
}
|
||||
|
||||
interface IFetcher {
|
||||
(input: string, init: { method: string; headers: Record<string, string> }): Promise<CommonResponse>;
|
||||
}
|
||||
|
||||
export interface IFetchResourceMetadataOptions {
|
||||
/**
|
||||
* Headers to include only when the resource metadata URL has the same origin as the target resource
|
||||
*/
|
||||
sameOriginHeaders?: Record<string, string>;
|
||||
/**
|
||||
* Optional custom fetch implementation (defaults to global fetch)
|
||||
*/
|
||||
fetch?: IFetcher;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches and validates OAuth 2.0 protected resource metadata from the given URL.
|
||||
*
|
||||
* @param targetResource The target resource URL to compare origins with (e.g., the MCP server URL)
|
||||
* @param resourceMetadataUrl Optional URL to fetch the resource metadata from. If not provided, will try well-known URIs.
|
||||
* @param options Configuration options for the fetch operation
|
||||
* @returns Promise that resolves to the validated resource metadata
|
||||
* @throws Error if the fetch fails, returns non-200 status, or the response is invalid
|
||||
*/
|
||||
export async function fetchResourceMetadata(
|
||||
targetResource: string,
|
||||
resourceMetadataUrl: string | undefined,
|
||||
options: IFetchResourceMetadataOptions = {}
|
||||
): Promise<IAuthorizationProtectedResourceMetadata> {
|
||||
const {
|
||||
sameOriginHeaders = {},
|
||||
fetch: fetchImpl = fetch
|
||||
} = options;
|
||||
|
||||
const targetResourceUrlObj = new URL(targetResource);
|
||||
|
||||
// If no resourceMetadataUrl is provided, try well-known URIs as per RFC 9728
|
||||
let urlsToTry: string[];
|
||||
if (!resourceMetadataUrl) {
|
||||
// Try in order: 1) with path appended, 2) at root
|
||||
const pathComponent = targetResourceUrlObj.pathname === '/' ? undefined : targetResourceUrlObj.pathname;
|
||||
const rootUrl = `${targetResourceUrlObj.origin}${AUTH_PROTECTED_RESOURCE_METADATA_DISCOVERY_PATH}`;
|
||||
if (pathComponent) {
|
||||
// Only try both URLs if we have a path component
|
||||
urlsToTry = [
|
||||
`${rootUrl}${pathComponent}`,
|
||||
rootUrl
|
||||
];
|
||||
} else {
|
||||
// If target is already at root, only try the root URL once
|
||||
urlsToTry = [rootUrl];
|
||||
}
|
||||
} else {
|
||||
urlsToTry = [resourceMetadataUrl];
|
||||
}
|
||||
|
||||
const errors: Error[] = [];
|
||||
for (const urlToTry of urlsToTry) {
|
||||
try {
|
||||
// Determine if we should include same-origin headers
|
||||
let headers: Record<string, string> = {
|
||||
'Accept': 'application/json'
|
||||
};
|
||||
|
||||
const resourceMetadataUrlObj = new URL(urlToTry);
|
||||
if (resourceMetadataUrlObj.origin === targetResourceUrlObj.origin) {
|
||||
headers = {
|
||||
...headers,
|
||||
...sameOriginHeaders
|
||||
};
|
||||
}
|
||||
|
||||
const response = await fetchImpl(urlToTry, { method: 'GET', headers });
|
||||
if (response.status !== 200) {
|
||||
let errorText: string;
|
||||
try {
|
||||
errorText = await response.text();
|
||||
} catch {
|
||||
errorText = response.statusText;
|
||||
}
|
||||
errors.push(new Error(`Failed to fetch resource metadata from ${urlToTry}: ${response.status} ${errorText}`));
|
||||
continue;
|
||||
}
|
||||
|
||||
const body = await response.json();
|
||||
if (isAuthorizationProtectedResourceMetadata(body)) {
|
||||
// Use URL constructor for normalization - it handles hostname case and trailing slashes
|
||||
const prmValue = new URL(body.resource).toString();
|
||||
const targetValue = targetResourceUrlObj.toString();
|
||||
if (prmValue !== targetValue) {
|
||||
throw new Error(`Protected Resource Metadata resource property value "${prmValue}" (length: ${prmValue.length}) does not match target server url "${targetValue}" (length: ${targetValue.length}). These MUST match to follow OAuth spec https://datatracker.ietf.org/doc/html/rfc9728#PRConfigurationValidation`);
|
||||
}
|
||||
return body;
|
||||
} else {
|
||||
errors.push(new Error(`Invalid resource metadata from ${urlToTry}. Expected to follow shape of https://datatracker.ietf.org/doc/html/rfc9728#name-protected-resource-metadata (Hints: is scopes_supported an array? Is resource a string?). Current payload: ${JSON.stringify(body)}`));
|
||||
continue;
|
||||
}
|
||||
} catch (e) {
|
||||
errors.push(e instanceof Error ? e : new Error(String(e)));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// If we've tried all URLs and none worked, throw the error(s)
|
||||
if (errors.length === 1) {
|
||||
throw errors[0];
|
||||
} else {
|
||||
throw new AggregateError(errors, 'Failed to fetch resource metadata from all attempted URLs');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
isAuthorizationTokenResponse,
|
||||
parseWWWAuthenticateHeader,
|
||||
fetchDynamicRegistration,
|
||||
fetchResourceMetadata,
|
||||
scopesMatch,
|
||||
IAuthorizationJWTClaims,
|
||||
IAuthorizationServerMetadata,
|
||||
@@ -831,4 +832,478 @@ suite('OAuth', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
suite('fetchResourceMetadata', () => {
|
||||
let sandbox: sinon.SinonSandbox;
|
||||
let fetchStub: sinon.SinonStub;
|
||||
|
||||
setup(() => {
|
||||
sandbox = sinon.createSandbox();
|
||||
fetchStub = sandbox.stub();
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
sandbox.restore();
|
||||
});
|
||||
|
||||
test('should successfully fetch and validate resource metadata', async () => {
|
||||
const targetResource = 'https://example.com/api';
|
||||
const resourceMetadataUrl = 'https://example.com/.well-known/oauth-protected-resource';
|
||||
const expectedMetadata = {
|
||||
resource: 'https://example.com/api',
|
||||
scopes_supported: ['read', 'write']
|
||||
};
|
||||
|
||||
fetchStub.resolves({
|
||||
status: 200,
|
||||
json: async () => expectedMetadata,
|
||||
text: async () => JSON.stringify(expectedMetadata)
|
||||
});
|
||||
|
||||
const result = await fetchResourceMetadata(
|
||||
targetResource,
|
||||
resourceMetadataUrl,
|
||||
{ fetch: fetchStub }
|
||||
);
|
||||
|
||||
assert.deepStrictEqual(result, expectedMetadata);
|
||||
assert.strictEqual(fetchStub.callCount, 1);
|
||||
assert.strictEqual(fetchStub.firstCall.args[0], resourceMetadataUrl);
|
||||
assert.strictEqual(fetchStub.firstCall.args[1].method, 'GET');
|
||||
assert.strictEqual(fetchStub.firstCall.args[1].headers['Accept'], 'application/json');
|
||||
});
|
||||
|
||||
test('should include same-origin headers when origins match', async () => {
|
||||
const targetResource = 'https://example.com/api';
|
||||
const resourceMetadataUrl = 'https://example.com/.well-known/oauth-protected-resource';
|
||||
const sameOriginHeaders = {
|
||||
'X-Test-Header': 'test-value',
|
||||
'X-Custom-Header': 'value'
|
||||
};
|
||||
const expectedMetadata = {
|
||||
resource: 'https://example.com/api'
|
||||
};
|
||||
|
||||
fetchStub.resolves({
|
||||
status: 200,
|
||||
json: async () => expectedMetadata,
|
||||
text: async () => JSON.stringify(expectedMetadata)
|
||||
});
|
||||
|
||||
await fetchResourceMetadata(
|
||||
targetResource,
|
||||
resourceMetadataUrl,
|
||||
{ fetch: fetchStub, sameOriginHeaders }
|
||||
);
|
||||
|
||||
const headers = fetchStub.firstCall.args[1].headers;
|
||||
assert.strictEqual(headers['Accept'], 'application/json');
|
||||
assert.strictEqual(headers['X-Test-Header'], 'test-value');
|
||||
assert.strictEqual(headers['X-Custom-Header'], 'value');
|
||||
});
|
||||
|
||||
test('should not include same-origin headers when origins differ', async () => {
|
||||
const targetResource = 'https://example.com/api';
|
||||
const resourceMetadataUrl = 'https://other-domain.com/.well-known/oauth-protected-resource';
|
||||
const sameOriginHeaders = {
|
||||
'X-Test-Header': 'test-value'
|
||||
};
|
||||
const expectedMetadata = {
|
||||
resource: 'https://example.com/api'
|
||||
};
|
||||
|
||||
fetchStub.resolves({
|
||||
status: 200,
|
||||
json: async () => expectedMetadata,
|
||||
text: async () => JSON.stringify(expectedMetadata)
|
||||
});
|
||||
|
||||
await fetchResourceMetadata(
|
||||
targetResource,
|
||||
resourceMetadataUrl,
|
||||
{ fetch: fetchStub, sameOriginHeaders }
|
||||
);
|
||||
|
||||
const headers = fetchStub.firstCall.args[1].headers;
|
||||
assert.strictEqual(headers['Accept'], 'application/json');
|
||||
assert.strictEqual(headers['X-Test-Header'], undefined);
|
||||
});
|
||||
|
||||
test('should throw error when fetch returns non-200 status', async () => {
|
||||
const targetResource = 'https://example.com/api';
|
||||
const resourceMetadataUrl = 'https://example.com/.well-known/oauth-protected-resource';
|
||||
|
||||
fetchStub.resolves({
|
||||
status: 404,
|
||||
text: async () => 'Not Found'
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
async () => fetchResourceMetadata(targetResource, resourceMetadataUrl, { fetch: fetchStub }),
|
||||
/Failed to fetch resource metadata from.*404 Not Found/
|
||||
);
|
||||
});
|
||||
|
||||
test('should handle error when response.text() throws', async () => {
|
||||
const targetResource = 'https://example.com/api';
|
||||
const resourceMetadataUrl = 'https://example.com/.well-known/oauth-protected-resource';
|
||||
|
||||
fetchStub.resolves({
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
text: async () => { throw new Error('Cannot read response'); }
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
async () => fetchResourceMetadata(targetResource, resourceMetadataUrl, { fetch: fetchStub }),
|
||||
/Failed to fetch resource metadata from.*500 Internal Server Error/
|
||||
);
|
||||
});
|
||||
|
||||
test('should throw error when resource property does not match target resource', async () => {
|
||||
const targetResource = 'https://example.com/api';
|
||||
const resourceMetadataUrl = 'https://example.com/.well-known/oauth-protected-resource';
|
||||
const metadata = {
|
||||
resource: 'https://different.com/api'
|
||||
};
|
||||
|
||||
fetchStub.resolves({
|
||||
status: 200,
|
||||
json: async () => metadata,
|
||||
text: async () => JSON.stringify(metadata)
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
async () => fetchResourceMetadata(targetResource, resourceMetadataUrl, { fetch: fetchStub }),
|
||||
/Protected Resource Metadata resource property value.*does not match target server url.*These MUST match to follow OAuth spec/
|
||||
);
|
||||
});
|
||||
|
||||
test('should normalize URLs when comparing resource values', async () => {
|
||||
const targetResource = 'https://EXAMPLE.COM/api';
|
||||
const resourceMetadataUrl = 'https://example.com/.well-known/oauth-protected-resource';
|
||||
const metadata = {
|
||||
resource: 'https://example.com/api'
|
||||
};
|
||||
|
||||
fetchStub.resolves({
|
||||
status: 200,
|
||||
json: async () => metadata,
|
||||
text: async () => JSON.stringify(metadata)
|
||||
});
|
||||
|
||||
// URL normalization should handle hostname case differences
|
||||
const result = await fetchResourceMetadata(targetResource, resourceMetadataUrl, { fetch: fetchStub });
|
||||
assert.deepStrictEqual(result, metadata);
|
||||
});
|
||||
|
||||
test('should normalize hostnames when comparing resource values', async () => {
|
||||
const targetResource = 'https://EXAMPLE.COM/api';
|
||||
const resourceMetadataUrl = 'https://example.com/.well-known/oauth-protected-resource';
|
||||
const metadata = {
|
||||
resource: 'https://example.com/api'
|
||||
};
|
||||
|
||||
fetchStub.resolves({
|
||||
status: 200,
|
||||
json: async () => metadata,
|
||||
text: async () => JSON.stringify(metadata)
|
||||
});
|
||||
|
||||
const result = await fetchResourceMetadata(targetResource, resourceMetadataUrl, { fetch: fetchStub });
|
||||
assert.deepStrictEqual(result, metadata);
|
||||
});
|
||||
|
||||
test('should throw error when response is not valid resource metadata', async () => {
|
||||
const targetResource = 'https://example.com/api';
|
||||
const resourceMetadataUrl = 'https://example.com/.well-known/oauth-protected-resource';
|
||||
const invalidMetadata = {
|
||||
// Missing required 'resource' property
|
||||
scopes_supported: ['read', 'write']
|
||||
};
|
||||
|
||||
fetchStub.resolves({
|
||||
status: 200,
|
||||
json: async () => invalidMetadata,
|
||||
text: async () => JSON.stringify(invalidMetadata)
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
async () => fetchResourceMetadata(targetResource, resourceMetadataUrl, { fetch: fetchStub }),
|
||||
/Invalid resource metadata.*Expected to follow shape of.*is scopes_supported an array\? Is resource a string\?/
|
||||
);
|
||||
});
|
||||
|
||||
test('should throw error when scopes_supported is not an array', async () => {
|
||||
const targetResource = 'https://example.com/api';
|
||||
const resourceMetadataUrl = 'https://example.com/.well-known/oauth-protected-resource';
|
||||
const invalidMetadata = {
|
||||
resource: 'https://example.com/api',
|
||||
scopes_supported: 'not an array'
|
||||
};
|
||||
|
||||
fetchStub.resolves({
|
||||
status: 200,
|
||||
json: async () => invalidMetadata,
|
||||
text: async () => JSON.stringify(invalidMetadata)
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
async () => fetchResourceMetadata(targetResource, resourceMetadataUrl, { fetch: fetchStub }),
|
||||
/Invalid resource metadata/
|
||||
);
|
||||
});
|
||||
|
||||
test('should handle metadata with optional fields', async () => {
|
||||
const targetResource = 'https://example.com/api';
|
||||
const resourceMetadataUrl = 'https://example.com/.well-known/oauth-protected-resource';
|
||||
const metadata = {
|
||||
resource: 'https://example.com/api',
|
||||
resource_name: 'Example API',
|
||||
authorization_servers: ['https://auth.example.com'],
|
||||
jwks_uri: 'https://example.com/jwks',
|
||||
scopes_supported: ['read', 'write', 'admin'],
|
||||
bearer_methods_supported: ['header', 'body'],
|
||||
resource_documentation: 'https://example.com/docs'
|
||||
};
|
||||
|
||||
fetchStub.resolves({
|
||||
status: 200,
|
||||
json: async () => metadata,
|
||||
text: async () => JSON.stringify(metadata)
|
||||
});
|
||||
|
||||
const result = await fetchResourceMetadata(targetResource, resourceMetadataUrl, { fetch: fetchStub });
|
||||
assert.deepStrictEqual(result, metadata);
|
||||
});
|
||||
|
||||
test('should use global fetch when custom fetch is not provided', async () => {
|
||||
const targetResource = 'https://example.com/api';
|
||||
const resourceMetadataUrl = 'https://example.com/.well-known/oauth-protected-resource';
|
||||
const metadata = {
|
||||
resource: 'https://example.com/api'
|
||||
};
|
||||
|
||||
const globalFetchStub = sandbox.stub(globalThis, 'fetch').resolves({
|
||||
status: 200,
|
||||
json: async () => metadata,
|
||||
text: async () => JSON.stringify(metadata)
|
||||
} as any);
|
||||
|
||||
const result = await fetchResourceMetadata(targetResource, resourceMetadataUrl);
|
||||
|
||||
assert.deepStrictEqual(result, metadata);
|
||||
assert.strictEqual(globalFetchStub.callCount, 1);
|
||||
});
|
||||
|
||||
test('should handle same origin with different ports', async () => {
|
||||
const targetResource = 'https://example.com:8080/api';
|
||||
const resourceMetadataUrl = 'https://example.com:9090/.well-known/oauth-protected-resource';
|
||||
const sameOriginHeaders = {
|
||||
'X-Test-Header': 'test-value'
|
||||
};
|
||||
const metadata = {
|
||||
resource: 'https://example.com:8080/api'
|
||||
};
|
||||
|
||||
fetchStub.resolves({
|
||||
status: 200,
|
||||
json: async () => metadata,
|
||||
text: async () => JSON.stringify(metadata)
|
||||
});
|
||||
|
||||
await fetchResourceMetadata(
|
||||
targetResource,
|
||||
resourceMetadataUrl,
|
||||
{ fetch: fetchStub, sameOriginHeaders }
|
||||
);
|
||||
|
||||
// Different ports mean different origins
|
||||
const headers = fetchStub.firstCall.args[1].headers;
|
||||
assert.strictEqual(headers['X-Test-Header'], undefined);
|
||||
});
|
||||
|
||||
test('should handle same origin with different protocols', async () => {
|
||||
const targetResource = 'http://example.com/api';
|
||||
const resourceMetadataUrl = 'https://example.com/.well-known/oauth-protected-resource';
|
||||
const sameOriginHeaders = {
|
||||
'X-Test-Header': 'test-value'
|
||||
};
|
||||
const metadata = {
|
||||
resource: 'http://example.com/api'
|
||||
};
|
||||
|
||||
fetchStub.resolves({
|
||||
status: 200,
|
||||
json: async () => metadata,
|
||||
text: async () => JSON.stringify(metadata)
|
||||
});
|
||||
|
||||
await fetchResourceMetadata(
|
||||
targetResource,
|
||||
resourceMetadataUrl,
|
||||
{ fetch: fetchStub, sameOriginHeaders }
|
||||
);
|
||||
|
||||
// Different protocols mean different origins
|
||||
const headers = fetchStub.firstCall.args[1].headers;
|
||||
assert.strictEqual(headers['X-Test-Header'], undefined);
|
||||
});
|
||||
|
||||
test('should include error details in message with length information', async () => {
|
||||
const targetResource = 'https://example.com/api';
|
||||
const resourceMetadataUrl = 'https://example.com/.well-known/oauth-protected-resource';
|
||||
const metadata = {
|
||||
resource: 'https://different.com/other'
|
||||
};
|
||||
|
||||
fetchStub.resolves({
|
||||
status: 200,
|
||||
json: async () => metadata,
|
||||
text: async () => JSON.stringify(metadata)
|
||||
});
|
||||
|
||||
try {
|
||||
await fetchResourceMetadata(targetResource, resourceMetadataUrl, { fetch: fetchStub });
|
||||
assert.fail('Should have thrown an error');
|
||||
} catch (error: any) {
|
||||
assert.ok(/length:/.test(error.message), 'Error message should include length information');
|
||||
assert.ok(/https:\/\/different\.com\/other/.test(error.message), 'Error message should include actual resource value');
|
||||
assert.ok(/https:\/\/example\.com\/api/.test(error.message), 'Error message should include expected resource value');
|
||||
}
|
||||
});
|
||||
|
||||
test('should fallback to well-known URI with path when no resourceMetadataUrl provided', async () => {
|
||||
const targetResource = 'https://example.com/api/v1';
|
||||
const expectedMetadata = {
|
||||
resource: 'https://example.com/api/v1',
|
||||
scopes_supported: ['read', 'write']
|
||||
};
|
||||
|
||||
fetchStub.resolves({
|
||||
status: 200,
|
||||
json: async () => expectedMetadata,
|
||||
text: async () => JSON.stringify(expectedMetadata)
|
||||
});
|
||||
|
||||
const result = await fetchResourceMetadata(
|
||||
targetResource,
|
||||
undefined,
|
||||
{ fetch: fetchStub }
|
||||
);
|
||||
|
||||
assert.deepStrictEqual(result, expectedMetadata);
|
||||
assert.strictEqual(fetchStub.callCount, 1);
|
||||
// Should try path-appended version first
|
||||
assert.strictEqual(fetchStub.firstCall.args[0], 'https://example.com/.well-known/oauth-protected-resource/api/v1');
|
||||
});
|
||||
|
||||
test('should fallback to well-known URI at root when path version fails', async () => {
|
||||
const targetResource = 'https://example.com/api/v1';
|
||||
const expectedMetadata = {
|
||||
resource: 'https://example.com/api/v1',
|
||||
scopes_supported: ['read', 'write']
|
||||
};
|
||||
|
||||
// First call fails, second succeeds
|
||||
fetchStub.onFirstCall().resolves({
|
||||
status: 404,
|
||||
text: async () => 'Not Found',
|
||||
statusText: 'Not Found'
|
||||
});
|
||||
|
||||
fetchStub.onSecondCall().resolves({
|
||||
status: 200,
|
||||
json: async () => expectedMetadata,
|
||||
text: async () => JSON.stringify(expectedMetadata)
|
||||
});
|
||||
|
||||
const result = await fetchResourceMetadata(
|
||||
targetResource,
|
||||
undefined,
|
||||
{ fetch: fetchStub }
|
||||
);
|
||||
|
||||
assert.deepStrictEqual(result, expectedMetadata);
|
||||
assert.strictEqual(fetchStub.callCount, 2);
|
||||
// First attempt with path
|
||||
assert.strictEqual(fetchStub.firstCall.args[0], 'https://example.com/.well-known/oauth-protected-resource/api/v1');
|
||||
// Second attempt at root
|
||||
assert.strictEqual(fetchStub.secondCall.args[0], 'https://example.com/.well-known/oauth-protected-resource');
|
||||
});
|
||||
|
||||
test('should throw error when all well-known URIs fail', async () => {
|
||||
const targetResource = 'https://example.com/api/v1';
|
||||
|
||||
fetchStub.resolves({
|
||||
status: 404,
|
||||
text: async () => 'Not Found',
|
||||
statusText: 'Not Found'
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
async () => fetchResourceMetadata(targetResource, undefined, { fetch: fetchStub }),
|
||||
(error: any) => {
|
||||
assert.ok(error instanceof AggregateError, 'Should be an AggregateError');
|
||||
assert.strictEqual(error.errors.length, 2, 'Should contain 2 errors');
|
||||
assert.ok(/Failed to fetch resource metadata from.*\/api\/v1.*404/.test(error.errors[0].message), 'First error should mention /api/v1 and 404');
|
||||
assert.ok(/Failed to fetch resource metadata from.*\.well-known.*404/.test(error.errors[1].message), 'Second error should mention .well-known and 404');
|
||||
return true;
|
||||
}
|
||||
); assert.strictEqual(fetchStub.callCount, 2);
|
||||
});
|
||||
|
||||
test('should not append path when target resource is root', async () => {
|
||||
const targetResource = 'https://example.com/';
|
||||
const expectedMetadata = {
|
||||
resource: 'https://example.com/',
|
||||
scopes_supported: ['read']
|
||||
};
|
||||
|
||||
fetchStub.resolves({
|
||||
status: 200,
|
||||
json: async () => expectedMetadata,
|
||||
text: async () => JSON.stringify(expectedMetadata)
|
||||
});
|
||||
|
||||
const result = await fetchResourceMetadata(
|
||||
targetResource,
|
||||
undefined,
|
||||
{ fetch: fetchStub }
|
||||
);
|
||||
|
||||
assert.deepStrictEqual(result, expectedMetadata);
|
||||
assert.strictEqual(fetchStub.callCount, 1);
|
||||
// Both URLs should be the same when path is /
|
||||
assert.strictEqual(fetchStub.firstCall.args[0], 'https://example.com/.well-known/oauth-protected-resource');
|
||||
});
|
||||
|
||||
test('should include same-origin headers when using well-known fallback', async () => {
|
||||
const targetResource = 'https://example.com/api';
|
||||
const sameOriginHeaders = {
|
||||
'X-Test-Header': 'test-value',
|
||||
'X-Custom-Header': 'value'
|
||||
};
|
||||
const expectedMetadata = {
|
||||
resource: 'https://example.com/api'
|
||||
};
|
||||
|
||||
fetchStub.resolves({
|
||||
status: 200,
|
||||
json: async () => expectedMetadata,
|
||||
text: async () => JSON.stringify(expectedMetadata)
|
||||
});
|
||||
|
||||
await fetchResourceMetadata(
|
||||
targetResource,
|
||||
undefined,
|
||||
{ fetch: fetchStub, sameOriginHeaders }
|
||||
);
|
||||
|
||||
const headers = fetchStub.firstCall.args[1].headers;
|
||||
assert.strictEqual(headers['Accept'], 'application/json');
|
||||
assert.strictEqual(headers['X-Test-Header'], 'test-value');
|
||||
assert.strictEqual(headers['X-Custom-Header'], 'value');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -242,6 +242,7 @@ export class FoldingController extends Disposable implements IEditorContribution
|
||||
this.localToDispose.add(this.hiddenRangeModel.onDidChange(hr => this.onHiddenRangesChanges(hr)));
|
||||
|
||||
this.updateScheduler = new Delayer<FoldingModel>(this.updateDebounceInfo.get(model));
|
||||
this.localToDispose.add(this.updateScheduler);
|
||||
|
||||
this.cursorChangedScheduler = new RunOnceScheduler(() => this.revealCursor(), 200);
|
||||
this.localToDispose.add(this.cursorChangedScheduler);
|
||||
|
||||
@@ -19,7 +19,7 @@ export class McpGalleryManifestService extends Disposable implements IMcpGallery
|
||||
}
|
||||
|
||||
constructor(
|
||||
@IProductService protected readonly productService: IProductService,
|
||||
@IProductService private readonly productService: IProductService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { DeferredPromise, raceCancellationError, Sequencer, timeout } from '../.
|
||||
import { CancellationToken, CancellationTokenSource } from '../../../base/common/cancellation.js';
|
||||
import { CancellationError } from '../../../base/common/errors.js';
|
||||
import { Disposable, DisposableMap, DisposableStore, IDisposable, toDisposable } from '../../../base/common/lifecycle.js';
|
||||
import { AUTH_SERVER_METADATA_DISCOVERY_PATH, getDefaultMetadataForUrl, IAuthorizationProtectedResourceMetadata, IAuthorizationServerMetadata, isAuthorizationProtectedResourceMetadata, isAuthorizationServerMetadata, OPENID_CONNECT_DISCOVERY_PATH, parseWWWAuthenticateHeader } from '../../../base/common/oauth.js';
|
||||
import { AUTH_SERVER_METADATA_DISCOVERY_PATH, fetchResourceMetadata, getDefaultMetadataForUrl, IAuthorizationProtectedResourceMetadata, IAuthorizationServerMetadata, isAuthorizationServerMetadata, OPENID_CONNECT_DISCOVERY_PATH, parseWWWAuthenticateHeader } from '../../../base/common/oauth.js';
|
||||
import { SSEParser } from '../../../base/common/sseParser.js';
|
||||
import { URI, UriComponents } from '../../../base/common/uri.js';
|
||||
import { ConfigurationTarget } from '../../../platform/configuration/common/configuration.js';
|
||||
@@ -345,24 +345,26 @@ export class McpHTTPHandle extends Disposable {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Second, fetch that url's well-known server metadata
|
||||
// Second, fetch the resource metadata either from the challenge URL or from well-known URIs
|
||||
let serverMetadataUrl: string | undefined;
|
||||
let scopesSupported: string[] | undefined;
|
||||
let resource: IAuthorizationProtectedResourceMetadata | undefined;
|
||||
if (resourceMetadataChallenge) {
|
||||
const resourceMetadata = await this._getResourceMetadata(resourceMetadataChallenge);
|
||||
// Use URL constructor for normalization - it handles hostname case and trailing slashes
|
||||
const prmValue = new URL(resourceMetadata.resource).toString();
|
||||
const mcpValue = new URL(mcpUrl).toString();
|
||||
if (prmValue !== mcpValue) {
|
||||
throw new Error(`Protected Resource Metadata resource value "${prmValue}" (length: ${prmValue.length}) does not match MCP server url "${mcpValue}" (length: ${mcpValue.length}). The MCP server must follow OAuth spec https://datatracker.ietf.org/doc/html/rfc9728#PRConfigurationValidation`);
|
||||
}
|
||||
try {
|
||||
const resourceMetadata = await fetchResourceMetadata(mcpUrl, resourceMetadataChallenge, {
|
||||
sameOriginHeaders: {
|
||||
...Object.fromEntries(this._launch.headers),
|
||||
'MCP-Protocol-Version': MCP.LATEST_PROTOCOL_VERSION
|
||||
},
|
||||
fetch: (url, init) => this._fetch(url, init)
|
||||
});
|
||||
// TODO:@TylerLeonhardt support multiple authorization servers
|
||||
// Consider using one that has an auth provider first, over the dynamic flow
|
||||
serverMetadataUrl = resourceMetadata.authorization_servers?.[0];
|
||||
this._log(LogLevel.Debug, `Using auth server metadata url: ${serverMetadataUrl}`);
|
||||
scopesSupported = resourceMetadata.scopes_supported;
|
||||
resource = resourceMetadata;
|
||||
} catch (e) {
|
||||
this._log(LogLevel.Debug, `Could not fetch resource metadata: ${String(e)}`);
|
||||
}
|
||||
|
||||
const baseUrl = new URL(originalResponse.url).origin;
|
||||
@@ -401,35 +403,6 @@ export class McpHTTPHandle extends Disposable {
|
||||
this._log(LogLevel.Info, 'Using default auth metadata');
|
||||
}
|
||||
|
||||
private async _getResourceMetadata(resourceMetadata: string): Promise<IAuthorizationProtectedResourceMetadata> {
|
||||
// detect if the resourceMetadata, which is a URL, is in the same origin as the MCP server
|
||||
const resourceMetadataUrl = new URL(resourceMetadata);
|
||||
const mcpServerUrl = new URL(this._launch.uri.toString(true));
|
||||
let additionalHeaders: Record<string, string> = {};
|
||||
if (resourceMetadataUrl.origin === mcpServerUrl.origin) {
|
||||
additionalHeaders = {
|
||||
...Object.fromEntries(this._launch.headers)
|
||||
};
|
||||
}
|
||||
const resourceMetadataResponse = await this._fetch(resourceMetadata, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
...additionalHeaders,
|
||||
'Accept': 'application/json',
|
||||
'MCP-Protocol-Version': MCP.LATEST_PROTOCOL_VERSION
|
||||
}
|
||||
});
|
||||
if (resourceMetadataResponse.status !== 200) {
|
||||
throw new Error(`Failed to fetch resource metadata: ${resourceMetadataResponse.status} ${await this._getErrText(resourceMetadataResponse)}`);
|
||||
}
|
||||
const body = await resourceMetadataResponse.json();
|
||||
if (isAuthorizationProtectedResourceMetadata(body)) {
|
||||
return body;
|
||||
} else {
|
||||
throw new Error(`Invalid resource metadata. Expected to follow shape of https://datatracker.ietf.org/doc/html/rfc9728#name-protected-resource-metadata (Hints: is scopes_supported an array? Is resource a string?). Current payload: ${JSON.stringify(body)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async _getAuthorizationServerMetadata(authorizationServer: string, addtionalHeaders: Record<string, string>): Promise<IAuthorizationServerMetadata> {
|
||||
// For the oauth server metadata discovery path, we _INSERT_
|
||||
// the well known path after the origin and before the path.
|
||||
|
||||
@@ -177,8 +177,8 @@
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.monaco-workbench:not(.hc-light):not(.hc-black) .part.statusbar > .items-container > .statusbar-item a:hover:not(.disabled) {
|
||||
background-color: var(--vscode-statusBarItem-hoverBackground);
|
||||
.monaco-workbench .part.statusbar > .items-container > .statusbar-item a:hover:not(.disabled) {
|
||||
background-color: var(--vscode-statusBarItem-hoverBackground) !important;
|
||||
}
|
||||
|
||||
/** Status bar entry item kinds */
|
||||
|
||||
@@ -336,18 +336,18 @@ export const STATUS_BAR_ITEM_FOCUS_BORDER = registerColor('statusBarItem.focusBo
|
||||
|
||||
export const STATUS_BAR_ITEM_HOVER_BACKGROUND = registerColor('statusBarItem.hoverBackground', {
|
||||
dark: Color.white.transparent(0.12),
|
||||
light: Color.white.transparent(0.12),
|
||||
hcDark: Color.white.transparent(0.12),
|
||||
hcLight: Color.black.transparent(0.12)
|
||||
light: Color.black.transparent(0.12),
|
||||
hcDark: Color.black,
|
||||
hcLight: Color.white
|
||||
}, localize('statusBarItemHoverBackground', "Status bar item background color when hovering. The status bar is shown in the bottom of the window."));
|
||||
|
||||
export const STATUS_BAR_ITEM_HOVER_FOREGROUND = registerColor('statusBarItem.hoverForeground', STATUS_BAR_FOREGROUND, localize('statusBarItemHoverForeground', "Status bar item foreground color when hovering. The status bar is shown in the bottom of the window."));
|
||||
|
||||
export const STATUS_BAR_ITEM_COMPACT_HOVER_BACKGROUND = registerColor('statusBarItem.compactHoverBackground', {
|
||||
dark: Color.white.transparent(0.20),
|
||||
light: Color.white.transparent(0.20),
|
||||
hcDark: Color.white.transparent(0.20),
|
||||
hcLight: Color.black.transparent(0.20)
|
||||
dark: Color.white.transparent(0.12),
|
||||
light: Color.black.transparent(0.12),
|
||||
hcDark: Color.black,
|
||||
hcLight: Color.white
|
||||
}, localize('statusBarItemCompactHoverBackground', "Status bar item background color when hovering an item that contains two hovers. The status bar is shown in the bottom of the window."));
|
||||
|
||||
export const STATUS_BAR_PROMINENT_ITEM_FOREGROUND = registerColor('statusBarItem.prominentForeground', STATUS_BAR_FOREGROUND, localize('statusBarProminentItemForeground', "Status bar prominent items foreground color. Prominent items stand out from other status bar entries to indicate importance. The status bar is shown in the bottom of the window."));
|
||||
|
||||
@@ -66,14 +66,13 @@ import { IChatSessionItem, IChatSessionsService } from '../../common/chatSession
|
||||
import { ChatSessionUri } from '../../common/chatUri.js';
|
||||
import { IChatRequestViewModel, IChatResponseViewModel, isRequestVM } from '../../common/chatViewModel.js';
|
||||
import { IChatWidgetHistoryService } from '../../common/chatWidgetHistoryService.js';
|
||||
import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../../common/constants.js';
|
||||
import { ChatAgentLocation, ChatConfiguration, ChatModeKind, VIEWLET_ID } from '../../common/constants.js';
|
||||
import { ILanguageModelChatSelector, ILanguageModelsService } from '../../common/languageModels.js';
|
||||
import { CopilotUsageExtensionFeatureId } from '../../common/languageModelStats.js';
|
||||
import { ILanguageModelToolsService } from '../../common/languageModelToolsService.js';
|
||||
import { ChatViewId, IChatWidget, IChatWidgetService, showChatView, showCopilotView } from '../chat.js';
|
||||
import { IChatEditorOptions } from '../chatEditor.js';
|
||||
import { ChatEditorInput, shouldShowClearEditingSessionConfirmation, showClearEditingSessionConfirmation } from '../chatEditorInput.js';
|
||||
import { VIEWLET_ID } from '../chatSessions/view/chatSessionsView.js';
|
||||
import { ChatViewPane } from '../chatViewPane.js';
|
||||
import { convertBufferToScreenshotVariable } from '../contrib/screenshot.js';
|
||||
import { clearChatEditor } from './chatClear.js';
|
||||
|
||||
@@ -28,7 +28,7 @@ import { ChatContextKeys } from '../../common/chatContextKeys.js';
|
||||
import { IChatService } from '../../common/chatService.js';
|
||||
import { IChatSessionsService } from '../../common/chatSessionsService.js';
|
||||
import { ChatSessionUri } from '../../common/chatUri.js';
|
||||
import { ChatConfiguration } from '../../common/constants.js';
|
||||
import { ChatConfiguration, VIEWLET_ID } from '../../common/constants.js';
|
||||
import { ChatViewId, IChatWidgetService } from '../chat.js';
|
||||
import { IChatEditorOptions } from '../chatEditor.js';
|
||||
import { ChatEditorInput } from '../chatEditorInput.js';
|
||||
@@ -36,7 +36,6 @@ import { ChatSessionItemWithProvider, findExistingChatEditorByUri, isLocalChatSe
|
||||
import { ChatViewPane } from '../chatViewPane.js';
|
||||
import { ACTION_ID_OPEN_CHAT, CHAT_CATEGORY } from './chatActions.js';
|
||||
import { CancellationToken } from '../../../../../base/common/cancellation.js';
|
||||
import { VIEWLET_ID } from '../chatSessions/view/chatSessionsView.js';
|
||||
|
||||
export interface IChatSessionContext {
|
||||
sessionId: string;
|
||||
|
||||
@@ -23,11 +23,10 @@ import { IChatAgentData, IChatAgentRequest, IChatAgentService } from '../common/
|
||||
import { ChatContextKeys } from '../common/chatContextKeys.js';
|
||||
import { ChatSession, ChatSessionStatus, IChatSessionContentProvider, IChatSessionItem, IChatSessionItemProvider, IChatSessionsExtensionPoint, IChatSessionsService } from '../common/chatSessionsService.js';
|
||||
import { ChatSessionUri } from '../common/chatUri.js';
|
||||
import { ChatAgentLocation, ChatModeKind } from '../common/constants.js';
|
||||
import { ChatAgentLocation, ChatModeKind, VIEWLET_ID } from '../common/constants.js';
|
||||
import { CHAT_CATEGORY } from './actions/chatActions.js';
|
||||
import { IChatEditorOptions } from './chatEditor.js';
|
||||
import { NEW_CHAT_SESSION_ACTION_ID } from './chatSessions/common.js';
|
||||
import { VIEWLET_ID } from './chatSessions/view/chatSessionsView.js';
|
||||
|
||||
const extensionPoint = ExtensionsRegistry.registerExtensionPoint<IChatSessionsExtensionPoint[]>({
|
||||
extensionPoint: 'chatSessions',
|
||||
|
||||
@@ -25,14 +25,12 @@ import { IChatEntitlementService } from '../../../../../services/chat/common/cha
|
||||
import { IExtensionService } from '../../../../../services/extensions/common/extensions.js';
|
||||
import { IWorkbenchLayoutService } from '../../../../../services/layout/browser/layoutService.js';
|
||||
import { IChatSessionsService, IChatSessionItemProvider, IChatSessionsExtensionPoint } from '../../../common/chatSessionsService.js';
|
||||
import { ChatConfiguration } from '../../../common/constants.js';
|
||||
import { ChatConfiguration, VIEWLET_ID } from '../../../common/constants.js';
|
||||
import { ACTION_ID_OPEN_CHAT } from '../../actions/chatActions.js';
|
||||
import { ChatSessionTracker } from '../chatSessionTracker.js';
|
||||
import { LocalChatSessionsProvider } from '../localChatSessionsProvider.js';
|
||||
import { SessionsViewPane } from './sessionsViewPane.js';
|
||||
|
||||
export const VIEWLET_ID = 'workbench.view.chat.sessions';
|
||||
|
||||
export class ChatSessionsView extends Disposable implements IWorkbenchContribution {
|
||||
static readonly ID = 'workbench.contrib.chatSessions';
|
||||
|
||||
|
||||
@@ -86,3 +86,5 @@ export namespace ChatAgentLocation {
|
||||
export const ChatUnsupportedFileSchemes = new Set([Schemas.vscodeChatEditor, Schemas.walkThrough, Schemas.vscodeChatSession, 'ccreq']);
|
||||
|
||||
export const TodoListWidgetPositionSettingId = 'chat.todoListWidget.position';
|
||||
|
||||
export const VIEWLET_ID = 'workbench.view.chat.sessions';
|
||||
|
||||
@@ -230,6 +230,10 @@ export class VariablesView extends ViewPane implements IDebugViewWithVariables {
|
||||
return !!e.treeItem.canEdit;
|
||||
}
|
||||
|
||||
if (!session.capabilities?.supportsSetVariable && !session.capabilities?.supportsSetExpression) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return e instanceof Variable && !e.presentationHint?.attributes?.includes('readOnly') && !e.presentationHint?.lazy;
|
||||
}
|
||||
|
||||
|
||||
@@ -37,22 +37,24 @@ export class McpResourcePickHelper {
|
||||
}
|
||||
|
||||
public static item(resource: IMcpResource | IMcpResourceTemplate): IQuickPickItem {
|
||||
const icon = resource.icons.getUrl(22);
|
||||
const iconPath = icon ? { dark: icon, light: icon } : undefined;
|
||||
if (isMcpResourceTemplate(resource)) {
|
||||
return {
|
||||
id: resource.template.template,
|
||||
label: resource.title || resource.name,
|
||||
description: resource.description,
|
||||
detail: localize('mcp.resource.template', 'Resource template: {0}', resource.template.template),
|
||||
iconPath,
|
||||
};
|
||||
}
|
||||
|
||||
const icon = resource.icons.getUrl(22);
|
||||
return {
|
||||
id: resource.uri.toString(),
|
||||
label: resource.title || resource.name,
|
||||
description: resource.description,
|
||||
detail: resource.mcpUri + (resource.sizeInBytes !== undefined ? ' (' + ByteSize.formatSize(resource.sizeInBytes) + ')' : ''),
|
||||
iconPath: icon ? { dark: icon, light: icon } : undefined,
|
||||
iconPath,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ interface IIcon {
|
||||
sizes: { width: number; height: number }[];
|
||||
}
|
||||
|
||||
export type ParsedMcpIcons = IIcon[];
|
||||
export type StoredMcpIcons = Dto<IIcon>[];
|
||||
|
||||
|
||||
@@ -68,8 +69,8 @@ function validateIcon(icon: MCP.Icon, launch: McpServerLaunch, logger: ILogger):
|
||||
return;
|
||||
}
|
||||
|
||||
export function parseAndValidateMcpIcon(icons: MCP.Icons, launch: McpServerLaunch, logger: ILogger): StoredMcpIcons {
|
||||
const result: StoredMcpIcons = [];
|
||||
export function parseAndValidateMcpIcon(icons: MCP.Icons, launch: McpServerLaunch, logger: ILogger): ParsedMcpIcons {
|
||||
const result: ParsedMcpIcons = [];
|
||||
for (const icon of icons.icons || []) {
|
||||
const uri = validateIcon(icon, launch, logger);
|
||||
if (!uri) {
|
||||
@@ -92,9 +93,12 @@ export function parseAndValidateMcpIcon(icons: MCP.Icons, launch: McpServerLaunc
|
||||
}
|
||||
|
||||
export class McpIcons implements IMcpIcons {
|
||||
|
||||
public static fromStored(icons: StoredMcpIcons | undefined) {
|
||||
return new McpIcons(icons?.map(i => ({ src: URI.revive(i.src), sizes: i.sizes })) || []);
|
||||
return McpIcons.fromParsed(icons?.map(i => ({ src: URI.revive(i.src), sizes: i.sizes })));
|
||||
}
|
||||
|
||||
public static fromParsed(icons: ParsedMcpIcons | undefined) {
|
||||
return new McpIcons(icons || []);
|
||||
}
|
||||
|
||||
protected constructor(private readonly _icons: IIcon[]) { }
|
||||
|
||||
@@ -489,7 +489,7 @@ export class McpServer extends Disposable implements IMcpServer {
|
||||
return new AsyncIterableProducer<IMcpResource[]>(async emitter => {
|
||||
await McpServer.callOn(this, async (handler) => {
|
||||
for await (const resource of handler.listResourcesIterable({}, cts.token)) {
|
||||
emitter.emitOne(resource.map(r => new McpResource(this, r, McpIcons.fromStored(this._parseIcons(r)))));
|
||||
emitter.emitOne(resource.map(r => new McpResource(this, r, McpIcons.fromParsed(this._parseIcons(r)))));
|
||||
if (cts.token.isCancellationRequested) {
|
||||
return;
|
||||
}
|
||||
@@ -501,7 +501,7 @@ export class McpServer extends Disposable implements IMcpServer {
|
||||
public resourceTemplates(token?: CancellationToken): Promise<IMcpResourceTemplate[]> {
|
||||
return McpServer.callOn(this, async (handler) => {
|
||||
const templates = await handler.listResourceTemplates({}, token);
|
||||
return templates.map(t => new McpResourceTemplate(this, t));
|
||||
return templates.map(t => new McpResourceTemplate(this, t, McpIcons.fromParsed(this._parseIcons(t))));
|
||||
}, token);
|
||||
}
|
||||
|
||||
@@ -1074,6 +1074,7 @@ class McpResourceTemplate implements IMcpResourceTemplate {
|
||||
constructor(
|
||||
private readonly _server: McpServer,
|
||||
private readonly _definition: MCP.ResourceTemplate,
|
||||
public readonly icons: IMcpIcons,
|
||||
) {
|
||||
this.name = _definition.name;
|
||||
this.description = _definition.description;
|
||||
|
||||
@@ -356,6 +356,7 @@ export interface IMcpResourceTemplate {
|
||||
readonly description?: string;
|
||||
readonly mimeType?: string;
|
||||
readonly template: UriTemplate;
|
||||
readonly icons: IMcpIcons;
|
||||
|
||||
/** Gets string completions for the given template part. */
|
||||
complete(templatePart: string, prefix: string, alreadyResolved: Record<string, string | string[]>, token: CancellationToken): Promise<string[]>;
|
||||
|
||||
@@ -635,7 +635,7 @@ export namespace MCP {/* JSON-RPC types */
|
||||
/**
|
||||
* A template description for resources available on the server.
|
||||
*/
|
||||
export interface ResourceTemplate extends BaseMetadata {
|
||||
export interface ResourceTemplate extends BaseMetadata, Icons {
|
||||
/**
|
||||
* A URI template (according to RFC 6570) that can be used to construct resource URIs.
|
||||
*
|
||||
|
||||
@@ -626,8 +626,10 @@ export async function renderReleaseNotesMarkdown(
|
||||
sanitizerConfig: {
|
||||
allowRelativeMediaPaths: true,
|
||||
allowedLinkProtocols: {
|
||||
override: [Schemas.http, Schemas.https, Schemas.command]
|
||||
}
|
||||
override: [Schemas.http, Schemas.https, Schemas.command, Schemas.codeSetting]
|
||||
},
|
||||
allowedTags: { augment: ['nav', 'svg', 'path'] },
|
||||
allowedAttributes: { augment: ['aria-role', 'viewBox', 'fill', 'xmlns', 'd'] }
|
||||
},
|
||||
markedExtensions: [{
|
||||
renderer: {
|
||||
|
||||
+2
-2
@@ -10,12 +10,12 @@
|
||||
<p>text</p>
|
||||
</blockquote>
|
||||
<div class="toc-nav-layout">
|
||||
|
||||
<nav id="toc-nav">
|
||||
<div>In this update</div>
|
||||
<ul>
|
||||
<li><a href="#chat">test</a></li>
|
||||
</ul>
|
||||
|
||||
</nav>
|
||||
<div class="notes-main">
|
||||
|
||||
<h2 id="test">Test</h2>
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
<p>Here is a setting: <code tabindex="0"><a aria-role="button" title="View or change setting" class="codesetting" href="code-setting://editor.wordWrap/on"><svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15 15" height="14" width="14"><path d="M9.1 4.4L8.6 2H7.4l-.5 2.4-.7.3-2-1.3-.9.8 1.3 2-.2.7-2.4.5v1.2l2.4.5.3.8-1.3 2 .8.8 2-1.3.8.3.4 2.3h1.2l.5-2.4.8-.3 2 1.3.8-.8-1.3-2 .3-.8 2.3-.4V7.4l-2.4-.5-.3-.8 1.3-2-.8-.8-2 1.3-.7-.2zM9.4 1l.5 2.4L12 2.1l2 2-1.4 2.1 2.4.4v2.8l-2.4.5L14 12l-2 2-2.1-1.4-.5 2.4H6.6l-.5-2.4L4 13.9l-2-2 1.4-2.1L1 9.4V6.6l2.4-.5L2.1 4l2-2 2.1 1.4.4-2.4h2.8zm.6 7c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2zM8 9c.6 0 1-.4 1-1s-.4-1-1-1-1 .4-1 1 .4 1 1 1z"></path></svg>
|
||||
<span class="separator"></span>
|
||||
<span class="setting-name">editor.wordWrap</span>
|
||||
</a></code> and another <code tabindex="0"><a aria-role="button" title="View or change setting" class="codesetting" href="code-setting://editor.wordWrap/off"><svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15 15" height="14" width="14"><path d="M9.1 4.4L8.6 2H7.4l-.5 2.4-.7.3-2-1.3-.9.8 1.3 2-.2.7-2.4.5v1.2l2.4.5.3.8-1.3 2 .8.8 2-1.3.8.3.4 2.3h1.2l.5-2.4.8-.3 2 1.3.8-.8-1.3-2 .3-.8 2.3-.4V7.4l-2.4-.5-.3-.8 1.3-2-.8-.8-2 1.3-.7-.2zM9.4 1l.5 2.4L12 2.1l2 2-1.4 2.1 2.4.4v2.8l-2.4.5L14 12l-2 2-2.1-1.4-.5 2.4H6.6l-.5-2.4L4 13.9l-2-2 1.4-2.1L1 9.4V6.6l2.4-.5L2.1 4l2-2 2.1 1.4.4-2.4h2.8zm.6 7c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2zM8 9c.6 0 1-.4 1-1s-.4-1-1-1-1 .4-1 1 .4 1 1 1z"></path></svg>
|
||||
<span class="separator"></span>
|
||||
<span class="setting-name">editor.wordWrap</span>
|
||||
</a></code></p>
|
||||
@@ -10,7 +10,10 @@ import { IContextMenuService } from '../../../../../platform/contextview/browser
|
||||
import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js';
|
||||
import { IExtensionService } from '../../../../services/extensions/common/extensions.js';
|
||||
import { SimpleSettingRenderer } from '../../../markdown/browser/markdownSettingRenderer.js';
|
||||
import { IPreferencesService } from '../../../../services/preferences/common/preferences.js';
|
||||
import { renderReleaseNotesMarkdown } from '../../browser/releaseNotesEditor.js';
|
||||
import { URI } from '../../../../../base/common/uri.js';
|
||||
import { Emitter } from '../../../../../base/common/event.js';
|
||||
|
||||
|
||||
suite('Release notes renderer', () => {
|
||||
@@ -55,4 +58,48 @@ Navigation End -->
|
||||
const result = await renderReleaseNotesMarkdown(content, extensionService, languageService, instantiationService.createInstance(SimpleSettingRenderer));
|
||||
await assertSnapshot(result.toString());
|
||||
});
|
||||
|
||||
test('Should render code settings', async () => {
|
||||
// Stub preferences service with a known setting so the SimpleSettingRenderer treats it as valid
|
||||
const testSettingId = 'editor.wordWrap';
|
||||
instantiationService.stub(IPreferencesService, <Partial<IPreferencesService>>{
|
||||
_serviceBrand: undefined,
|
||||
onDidDefaultSettingsContentChanged: new Emitter<URI>().event,
|
||||
userSettingsResource: undefined as any,
|
||||
workspaceSettingsResource: null,
|
||||
getFolderSettingsResource: () => null,
|
||||
createPreferencesEditorModel: async () => null,
|
||||
getDefaultSettingsContent: () => undefined,
|
||||
hasDefaultSettingsContent: () => false,
|
||||
createSettings2EditorModel: () => { throw new Error('not needed'); },
|
||||
openPreferences: async () => undefined,
|
||||
openRawDefaultSettings: async () => undefined,
|
||||
openSettings: async () => undefined,
|
||||
openApplicationSettings: async () => undefined,
|
||||
openUserSettings: async () => undefined,
|
||||
openRemoteSettings: async () => undefined,
|
||||
openWorkspaceSettings: async () => undefined,
|
||||
openFolderSettings: async () => undefined,
|
||||
openGlobalKeybindingSettings: async () => undefined,
|
||||
openDefaultKeybindingsFile: async () => undefined,
|
||||
openLanguageSpecificSettings: async () => undefined,
|
||||
getEditableSettingsURI: async () => null,
|
||||
getSetting: (id: string) => {
|
||||
if (id === testSettingId) {
|
||||
// Provide the minimal fields accessed by SimpleSettingRenderer
|
||||
return <any>{
|
||||
key: testSettingId,
|
||||
value: 'off',
|
||||
type: 'string'
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
createSplitJsonEditorInput: () => { throw new Error('not needed'); }
|
||||
});
|
||||
|
||||
const content = `Here is a setting: \`setting(${testSettingId}:on)\` and another \`setting(${testSettingId}:off)\``;
|
||||
const result = await renderReleaseNotesMarkdown(content, extensionService, languageService, instantiationService.createInstance(SimpleSettingRenderer));
|
||||
await assertSnapshot(result.toString());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,10 +55,6 @@ export class WorkbenchMcpGalleryManifestService extends McpGalleryManifestServic
|
||||
}
|
||||
|
||||
private async doGetMcpGalleryManifest(): Promise<void> {
|
||||
if (this.productService.quality === 'stable') {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.getAndUpdateMcpGalleryManifest();
|
||||
|
||||
this._register(this.configurationService.onDidChangeConfiguration(e => {
|
||||
|
||||
Vendored
+7
-7
@@ -1666,7 +1666,7 @@ declare module 'vscode' {
|
||||
/**
|
||||
* An {@link Event} which fires upon cancellation.
|
||||
*/
|
||||
onCancellationRequested: Event<any>;
|
||||
readonly onCancellationRequested: Event<any>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -8608,7 +8608,7 @@ declare module 'vscode' {
|
||||
/**
|
||||
* Fires when a secret is stored or deleted.
|
||||
*/
|
||||
onDidChange: Event<SecretStorageChangeEvent>;
|
||||
readonly onDidChange: Event<SecretStorageChangeEvent>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -13074,7 +13074,7 @@ declare module 'vscode' {
|
||||
* (Examples include: an explicit call to {@link QuickInput.hide},
|
||||
* the user pressing Esc, some other input UI opening, etc.)
|
||||
*/
|
||||
onDidHide: Event<void>;
|
||||
readonly onDidHide: Event<void>;
|
||||
|
||||
/**
|
||||
* Dispose of this input UI and any associated resources. If it is still
|
||||
@@ -18225,7 +18225,7 @@ declare module 'vscode' {
|
||||
* Fired when a user has changed whether this is a default profile. The
|
||||
* event contains the new value of {@link isDefault}
|
||||
*/
|
||||
onDidChangeDefault: Event<boolean>;
|
||||
readonly onDidChangeDefault: Event<boolean>;
|
||||
|
||||
/**
|
||||
* Whether this profile supports continuous running of requests. If so,
|
||||
@@ -18604,7 +18604,7 @@ declare module 'vscode' {
|
||||
* An event fired when the editor is no longer interested in data
|
||||
* associated with the test run.
|
||||
*/
|
||||
onDidDispose: Event<void>;
|
||||
readonly onDidDispose: Event<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -19680,7 +19680,7 @@ declare module 'vscode' {
|
||||
* The passed {@link ChatResultFeedback.result result} is guaranteed to have the same properties as the result that was
|
||||
* previously returned from this chat participant's handler.
|
||||
*/
|
||||
onDidReceiveFeedback: Event<ChatResultFeedback>;
|
||||
readonly onDidReceiveFeedback: Event<ChatResultFeedback>;
|
||||
|
||||
/**
|
||||
* Dispose this participant and free resources.
|
||||
@@ -20723,7 +20723,7 @@ declare module 'vscode' {
|
||||
/**
|
||||
* An event that fires when access information changes.
|
||||
*/
|
||||
onDidChange: Event<void>;
|
||||
readonly onDidChange: Event<void>;
|
||||
|
||||
/**
|
||||
* Checks if a request can be made to a language model.
|
||||
|
||||
+23
-42
@@ -12,7 +12,7 @@ import * as minimist from 'minimist';
|
||||
import * as vscodetest from '@vscode/test-electron';
|
||||
import fetch from 'node-fetch';
|
||||
import { Quality, MultiLogger, Logger, ConsoleLogger, FileLogger, measureAndLog, getDevElectronPath, getBuildElectronPath, getBuildVersion, ApplicationOptions } from '../../automation';
|
||||
import { retry, timeout } from './utils';
|
||||
import { retry } from './utils';
|
||||
|
||||
import { setup as setupDataLossTests } from './areas/workbench/data-loss.test';
|
||||
import { setup as setupPreferencesTests } from './areas/preferences/preferences.test';
|
||||
@@ -102,7 +102,7 @@ function createLogger(): Logger {
|
||||
}
|
||||
|
||||
// Prepare logs rot path
|
||||
fs.rmSync(logsRootPath, { recursive: true, force: true, maxRetries: 3 });
|
||||
fs.rmSync(logsRootPath, { recursive: true, force: true, maxRetries: 10, retryDelay: 1000 });
|
||||
fs.mkdirSync(logsRootPath, { recursive: true });
|
||||
|
||||
// Always log to log file
|
||||
@@ -117,19 +117,6 @@ try {
|
||||
logger.log(`Error enabling graceful-fs: ${error}`);
|
||||
}
|
||||
|
||||
const testDataPath = path.join(os.tmpdir(), 'vscsmoke');
|
||||
if (fs.existsSync(testDataPath)) {
|
||||
fs.rmSync(testDataPath, { recursive: true, force: true, maxRetries: 10 });
|
||||
}
|
||||
fs.mkdirSync(testDataPath, { recursive: true });
|
||||
process.once('exit', () => {
|
||||
try {
|
||||
fs.rmSync(testDataPath, { recursive: true, force: true, maxRetries: 10 });
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
});
|
||||
|
||||
function getTestTypeSuffix(): string {
|
||||
if (opts.web) {
|
||||
return 'browser';
|
||||
@@ -140,8 +127,21 @@ function getTestTypeSuffix(): string {
|
||||
}
|
||||
}
|
||||
|
||||
const testDataPath = path.join(os.tmpdir(), `vscsmoke-${getTestTypeSuffix()}`);
|
||||
if (fs.existsSync(testDataPath)) {
|
||||
fs.rmSync(testDataPath, { recursive: true, force: true, maxRetries: 10, retryDelay: 1000 });
|
||||
}
|
||||
fs.mkdirSync(testDataPath, { recursive: true });
|
||||
process.once('exit', () => {
|
||||
try {
|
||||
fs.rmSync(testDataPath, { recursive: true, force: true, maxRetries: 10, retryDelay: 1000 });
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
});
|
||||
|
||||
const testRepoUrl = 'https://github.com/microsoft/vscode-smoketest-express';
|
||||
const workspacePath = path.join(testDataPath, `vscode-smoketest-express-${getTestTypeSuffix()}`);
|
||||
const workspacePath = path.join(testDataPath, `vscode-smoketest-express`);
|
||||
const extensionsPath = path.join(testDataPath, 'extensions-dir');
|
||||
fs.mkdirSync(extensionsPath, { recursive: true });
|
||||
|
||||
@@ -245,7 +245,7 @@ const userDataDir = path.join(testDataPath, 'd');
|
||||
async function setupRepository(): Promise<void> {
|
||||
if (opts['test-repo']) {
|
||||
logger.log('Copying test project repository:', opts['test-repo']);
|
||||
fs.rmSync(workspacePath, { recursive: true, force: true, maxRetries: 10 });
|
||||
fs.rmSync(workspacePath, { recursive: true, force: true, maxRetries: 10, retryDelay: 1000 });
|
||||
// not platform friendly
|
||||
if (process.platform === 'win32') {
|
||||
cp.execSync(`xcopy /E "${opts['test-repo']}" "${workspacePath}"\\*`);
|
||||
@@ -314,15 +314,9 @@ async function ensureStableCode(): Promise<void> {
|
||||
},
|
||||
error: error => logger.log(`download stable code error: ${error}`)
|
||||
}
|
||||
}), 'download stable code', logger), 1000, 3, () => new Promise<void>((resolve, reject) => {
|
||||
fs.rm(stableCodeDestination, { recursive: true, force: true, maxRetries: 10 }, error => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}));
|
||||
}), 'download stable code', logger), 1000, 3, async () => {
|
||||
fs.rmSync(stableCodeDestination, { recursive: true, force: true, maxRetries: 10, retryDelay: 1000 });
|
||||
});
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
// Visual Studio Code.app/Contents/MacOS/Electron
|
||||
@@ -388,22 +382,9 @@ before(async function () {
|
||||
// After main suite (after all tests)
|
||||
after(async function () {
|
||||
try {
|
||||
let deleted = false;
|
||||
await measureAndLog(() => Promise.race([
|
||||
new Promise<void>((resolve, reject) => fs.rm(testDataPath, { recursive: true, force: true, maxRetries: 10 }, error => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
deleted = true;
|
||||
resolve();
|
||||
}
|
||||
})),
|
||||
timeout(30000).then(() => {
|
||||
if (!deleted) {
|
||||
throw new Error('giving up after 30s');
|
||||
}
|
||||
})
|
||||
]), 'rimraf(testDataPath)', logger);
|
||||
await measureAndLog(async () => {
|
||||
fs.rmSync(testDataPath, { recursive: true, force: true, maxRetries: 10, retryDelay: 1000 });
|
||||
}, 'rimraf(testDataPath)', logger);
|
||||
} catch (error) {
|
||||
logger.log(`Unable to delete smoke test workspace: ${error}. This indicates some process is locking the workspace folder.`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user