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
2 changes: 1 addition & 1 deletion .release-please-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"chat-client": "0.1.56",
"core/aws-lsp-core": "0.0.22",
"server/aws-lsp-antlr4": "0.1.26",
"server/aws-lsp-codewhisperer": "0.0.126",
"server/aws-lsp-codewhisperer": "0.0.127",
"server/aws-lsp-json": "0.1.27",
"server/aws-lsp-partiql": "0.0.24",
"server/aws-lsp-yaml": "0.1.27"
Expand Down
2 changes: 1 addition & 1 deletion app/aws-lsp-codewhisperer-runtimes/src/version.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
"agenticChat": "1.77.0"
"agenticChat": "1.78.0"
}
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions server/aws-lsp-codewhisperer/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Changelog

## [0.0.127](https://github.com/Amazon-Q-Developer/language-servers/compare/lsp-codewhisperer/v0.0.126...lsp-codewhisperer/v0.0.127) (2026-08-24)


### Bug Fixes

* **amazonq:** cover merged env and headers in MCP consent fingerprint ([#2851](https://github.com/Amazon-Q-Developer/language-servers/issues/2851)) ([#2853](https://github.com/Amazon-Q-Developer/language-servers/issues/2853)) ([529aed4](https://github.com/Amazon-Q-Developer/language-servers/commit/529aed43259503cf71b475fb3496de7bfab25f17))
* beam - flat-named transformed zips + lightweight discovery + IsLbvPending ([#2849](https://github.com/Amazon-Q-Developer/language-servers/issues/2849)) ([863c5bf](https://github.com/Amazon-Q-Developer/language-servers/commit/863c5bf00a6c30ec7658056a155f1ca1005127e6))
* surface backend interactive mode in getTransformInfo ([836b756](https://github.com/Amazon-Q-Developer/language-servers/commit/836b756ee2d8a553d9b26cf095e12979141e2367))

## [0.0.126](https://github.com/Amazon-Q-Developer/language-servers/compare/lsp-codewhisperer/v0.0.125...lsp-codewhisperer/v0.0.126) (2026-08-20)


Expand Down
2 changes: 1 addition & 1 deletion server/aws-lsp-codewhisperer/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@aws/lsp-codewhisperer",
"version": "0.0.126",
"version": "0.0.127",
"description": "CodeWhisperer Language Server",
"main": "out/index.js",
"repository": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import * as fs from 'fs'
import * as os from 'os'
import * as path from 'path'
import {
effectiveEnv,
effectiveHeaders,
fingerprintServerConfig,
fingerprintWorkspace,
hasApproval,
Expand Down Expand Up @@ -74,6 +76,71 @@ describe('mcpConsentStore', () => {
const b: MCPServerConfig = { url: 'https://b.example' }
expect(fingerprintServerConfig(a)).to.not.equal(fingerprintServerConfig(b))
})

// __additionalEnv__ / __additionalHeaders__ are merged into the spawn env/headers,
// so they must be covered by the fingerprint alongside the raw fields — otherwise a
// post-approval config edit to those fields would not re-prompt.
it('differs when __additionalEnv__ is added', () => {
const a: MCPServerConfig = { command: 'npx', args: ['-y', 's'], env: { LOG_LEVEL: 'info' } }
const b: MCPServerConfig = {
...a,
__additionalEnv__: { EXTRA: '1' },
}
expect(fingerprintServerConfig(a)).to.not.equal(fingerprintServerConfig(b))
})

it('differs when __additionalEnv__ overrides an existing env value', () => {
const a: MCPServerConfig = { command: 'sh', args: [], env: { FOO: '1' } }
const b: MCPServerConfig = { command: 'sh', args: [], env: { FOO: '1' }, __additionalEnv__: { FOO: '2' } }
expect(fingerprintServerConfig(a)).to.not.equal(fingerprintServerConfig(b))
})

it('differs when headers change', () => {
const a: MCPServerConfig = { url: 'https://a.example', headers: { Authorization: 'Bearer good' } }
const b: MCPServerConfig = { url: 'https://a.example', headers: { Authorization: 'Bearer attacker' } }
expect(fingerprintServerConfig(a)).to.not.equal(fingerprintServerConfig(b))
})

it('differs when __additionalHeaders__ overrides an existing header', () => {
const a: MCPServerConfig = { url: 'https://a.example', headers: { Authorization: 'Bearer good' } }
const b: MCPServerConfig = {
url: 'https://a.example',
headers: { Authorization: 'Bearer good' },
__additionalHeaders__: { Authorization: 'Bearer attacker' },
}
expect(fingerprintServerConfig(a)).to.not.equal(fingerprintServerConfig(b))
})

// Consent is about what will execute, not how the config is spelled: two configs
// that spawn the process with the identical effective env share a fingerprint.
it('hashes the merged spawn env, so the same effective env matches either field', () => {
const viaEnv: MCPServerConfig = { command: 'sh', args: [], env: { FOO: '1' } }
const viaAdditional: MCPServerConfig = { command: 'sh', args: [], __additionalEnv__: { FOO: '1' } }
expect(fingerprintServerConfig(viaEnv)).to.equal(fingerprintServerConfig(viaAdditional))
})

it('is stable regardless of __additionalEnv__ key order', () => {
const a: MCPServerConfig = { command: 'sh', args: [], __additionalEnv__: { A: '1', B: '2' } }
const b: MCPServerConfig = { command: 'sh', args: [], __additionalEnv__: { B: '2', A: '1' } }
expect(fingerprintServerConfig(a)).to.equal(fingerprintServerConfig(b))
})
})

describe('effectiveEnv / effectiveHeaders', () => {
it('merges __additionalEnv__ over env, matching the spawn-time merge', () => {
const cfg: MCPServerConfig = { env: { A: '1', B: '2' }, __additionalEnv__: { B: 'override', C: '3' } }
expect(effectiveEnv(cfg)).to.deep.equal({ A: '1', B: 'override', C: '3' })
})

it('merges __additionalHeaders__ over headers', () => {
const cfg: MCPServerConfig = { headers: { X: '1' }, __additionalHeaders__: { X: '2', Y: '3' } }
expect(effectiveHeaders(cfg)).to.deep.equal({ X: '2', Y: '3' })
})

it('returns an empty object when nothing is set', () => {
expect(effectiveEnv({})).to.deep.equal({})
expect(effectiveHeaders({})).to.deep.equal({})
})
})

describe('fingerprintWorkspace', () => {
Expand Down Expand Up @@ -183,11 +250,34 @@ describe('mcpConsentStore', () => {
const storeDir = path.join(tmpHome, '.aws', 'amazonq')
fs.mkdirSync(storeDir, { recursive: true })
fs.writeFileSync(path.join(storeDir, 'mcp-approvals.json'), JSON.stringify({ version: 999, approvals: [] }))
// record should still work (overwrites with v1)
// record should still work (overwrites with the current version)
await recordApproval(workspace, logger, 'poc', cfg, configPath)
expect(await hasApproval(workspace, logger, 'poc', cfg, configPath)).to.be.true
})

// STORE_VERSION 1 -> 2: v1 fingerprints were computed over a narrower field set and
// cannot be trusted to cover the merged env/headers, so they are discarded and the
// user is re-prompted once per workspace-scoped server after upgrade.
it('discards legacy v1 approvals so the user is re-prompted once after upgrade', async () => {
const storeDir = path.join(tmpHome, '.aws', 'amazonq')
fs.mkdirSync(storeDir, { recursive: true })
fs.writeFileSync(
path.join(storeDir, 'mcp-approvals.json'),
JSON.stringify({
version: 1,
approvals: [
{
serverName: 'poc',
fingerprint: fingerprintServerConfig(cfg),
workspaceHash: fingerprintWorkspace(configPath),
approvedAt: new Date().toISOString(),
},
],
})
)
expect(await hasApproval(workspace, logger, 'poc', cfg, configPath)).to.be.false
})

it('treats a malformed store as empty', async () => {
const storeDir = path.join(tmpHome, '.aws', 'amazonq')
fs.mkdirSync(storeDir, { recursive: true })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ import type { Workspace, Logging } from '@aws/language-server-runtimes/server-in
import type { MCPServerConfig } from './mcpTypes'

const APPROVALS_FILE = 'mcp-approvals.json'
const STORE_VERSION = 1
// v2: the fingerprint now covers the fully-merged spawn environment and headers
// (see fingerprintServerConfig). Bumping the version discards v1 approvals, which
// were computed over a narrower field set, so every workspace-scoped server is
// re-consented once after upgrade.
const STORE_VERSION = 2

interface Approval {
serverName: string
Expand All @@ -23,17 +27,49 @@ interface ApprovalStore {
approvals: Approval[]
}

function sortedRecord(rec?: Record<string, string>): Record<string, string> {
return rec ? Object.fromEntries(Object.entries(rec).sort(([a], [b]) => a.localeCompare(b))) : {}
}

/**
* The environment the stdio transport will actually spawn the server with, as far
* as the config controls it. Mirrors the merge in McpManager (`cfg.env` overlaid by
* `cfg.__additionalEnv__`).
*
* `__additionalEnv__` carries workspace/agent-level `env` for registry servers and is
* NOT folded into `cfg.env`, so it must be merged here or it escapes the fingerprint.
*/
export function effectiveEnv(cfg: MCPServerConfig): Record<string, string> {
return sortedRecord({ ...(cfg.env ?? {}), ...(cfg.__additionalEnv__ ?? {}) })
}

/**
* The headers the HTTP/SSE transport will actually send, as far as the config
* controls it. Mirrors the merge in McpManager (`cfg.headers` overlaid by
* `cfg.__additionalHeaders__`).
*/
export function effectiveHeaders(cfg: MCPServerConfig): Record<string, string> {
return sortedRecord({ ...(cfg.headers ?? {}), ...(cfg.__additionalHeaders__ ?? {}) })
}

/**
* SHA-256 of a canonical JSON form of the server's execution-relevant fields.
* Any change to command/args/env/url yields a new fingerprint, invalidating
* Any change to command/args/env/url/headers yields a new fingerprint, invalidating
* prior approvals — so mutation of the config re-prompts.
*
* `env` and `headers` are hashed in their *merged* form (see effectiveEnv /
* effectiveHeaders) so every field that reaches the spawned process is covered by
* consent. Two configs that spawn an identical process share a fingerprint regardless
* of which field supplied a value: consent is about what will execute, not how the
* config is spelled.
*/
export function fingerprintServerConfig(cfg: MCPServerConfig): string {
const canonical = {
command: cfg.command ?? null,
args: cfg.args ?? [],
env: cfg.env ? Object.fromEntries(Object.entries(cfg.env).sort(([a], [b]) => a.localeCompare(b))) : {},
env: effectiveEnv(cfg),
url: cfg.url ?? null,
headers: effectiveHeaders(cfg),
}
return 'sha256:' + createHash('sha256').update(JSON.stringify(canonical)).digest('hex')
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,14 @@ import { Mutex } from 'async-mutex'
import path = require('path')
import { URI } from 'vscode-uri'
import { MessageType } from '@aws/language-server-runtimes/protocol'
import { hasApproval, recordApproval, removeApproval, fingerprintServerConfig } from './mcpConsentStore'
import {
hasApproval,
recordApproval,
removeApproval,
fingerprintServerConfig,
effectiveEnv,
effectiveHeaders,
} from './mcpConsentStore'
import { sanitizeInput } from '../../../../shared/utils'
import { ProfileStatusMonitor } from './profileStatusMonitor'
import { OAuthClient } from './mcpOauthClient'
Expand Down Expand Up @@ -440,6 +447,17 @@ export class McpManager {
)
if (!approved) {
const cmdLine = [cfg.command ?? cfg.url ?? '(none)', ...(cfg.args ?? [])].join(' ').slice(0, 200)
// Surface the environment variables and headers the server will actually be
// launched with. Names only, never values: a config may legitimately hold
// tokens, and this string is shown in a dialog and written to logs. Values are
// covered by the fingerprint, so any value change re-prompts.
const envKeys = Object.keys(effectiveEnv(cfg))
const headerNames = Object.keys(effectiveHeaders(cfg))
const envLine =
envKeys.length > 0
? `Environment variables: ${envKeys.join(', ').slice(0, 200)}\n`
: `Environment variables: (none)\n`
const headerLine = headerNames.length > 0 ? `Headers: ${headerNames.join(', ').slice(0, 200)}\n` : ''
const allowBtn = { title: 'Allow for this server' }
const denyBtn = { title: 'Deny' }
let choice: { title: string } | null | undefined
Expand All @@ -451,8 +469,13 @@ export class McpManager {
`A workspace configuration file wants to start an MCP server.\n` +
`Server: ${serverName}\n` +
`Command: ${cmdLine}\n` +
envLine +
headerLine +
`Source: ${configPath}\n\n` +
`Running this server executes the above command on your machine. ` +
`Running this server executes the above command on your machine, ` +
`with the environment variables listed above. ` +
`Review them in the configuration file if you are unsure — variables such as ` +
`NODE_OPTIONS can cause additional code to run. ` +
`Only allow if you trust the authors of this workspace.\n\n` +
`Your choice will be remembered for this workspace. ` +
`If you allow, you won't be asked again unless the server configuration changes.`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,10 @@ export interface AtxGetTransformInfoResponse {
MissingPackageJsonPath?: string | null
DiffApplyFailed?: boolean
DiffApplyFailedStepIds?: string[]
// Interactive mode as resolved from the backend job objective (interactive_mode).
// Surfaced so the IDE can restore the correct mode instead of relying on its local
// settings store, which may be missing/stale (e.g. cold restart on another machine).
InteractiveMode?: InteractiveMode
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1770,6 +1770,12 @@ export class ATXTransformHandler {
result.DiffApplyFailed = true
result.DiffApplyFailedStepIds = diffContext.failedStepIds
}
// Surface the backend-resolved interactive mode (from job.objective) on every
// response so the IDE can restore it. Single injection point covers all internal
// return paths. cachedInteractiveMode is populated in _getTransformInfoInternal.
if (result && this.cachedInteractiveMode) {
result.InteractiveMode = this.cachedInteractiveMode
}
return result
} finally {
this._currentDiffContext = null
Expand Down
Loading