Skip to content

Commit 611c75a

Browse files
Merge main into feature/mono-repo
2 parents c809901 + 4a4210a commit 611c75a

7 files changed

Lines changed: 487 additions & 7 deletions

File tree

chat-client/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
},
2626
"dependencies": {
2727
"@aws/chat-client-ui-types": "0.1.68",
28-
"@aws/language-server-runtimes": "^0.3.16",
28+
"@aws/language-server-runtimes": "^0.3.17",
2929
"@aws/language-server-runtimes-types": "^0.1.64",
3030
"@aws/mynah-ui": "^4.40.1"
3131
},

package-lock.json

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

server/aws-lsp-codewhisperer/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
"@aws-sdk/util-arn-parser": "^3.723.0",
3939
"@aws-sdk/util-retry": "^3.374.0",
4040
"@aws/chat-client-ui-types": "0.1.68",
41-
"@aws/language-server-runtimes": "^0.3.16",
41+
"@aws/language-server-runtimes": "^0.3.17",
4242
"@aws/lsp-core": "^0.0.21",
4343
"@modelcontextprotocol/sdk": "^1.23.0",
4444
"@mozilla/readability": "^0.6.0",
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
/*!
2+
* Copyright Amazon.com, Inc. or its affiliates.
3+
* All Rights Reserved. SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import { expect } from 'chai'
7+
import * as fs from 'fs'
8+
import * as os from 'os'
9+
import * as path from 'path'
10+
import { fingerprintServerConfig, fingerprintWorkspace, hasApproval, recordApproval } from './mcpConsentStore'
11+
import type { MCPServerConfig } from './mcpTypes'
12+
13+
describe('mcpConsentStore', () => {
14+
let tmpHome: string
15+
let workspace: any
16+
let logger: any
17+
18+
beforeEach(() => {
19+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'mcpConsentTest-'))
20+
workspace = {
21+
fs: {
22+
exists: (p: string) => Promise.resolve(fs.existsSync(p)),
23+
readFile: (p: string) => Promise.resolve(Buffer.from(fs.readFileSync(p))),
24+
writeFile: (p: string, d: string) => Promise.resolve(fs.writeFileSync(p, d)),
25+
mkdir: (p: string, _opts: any) => Promise.resolve(fs.mkdirSync(p, { recursive: true })),
26+
getUserHomeDir: () => tmpHome,
27+
},
28+
}
29+
logger = { warn: () => {}, info: () => {}, error: () => {} }
30+
})
31+
32+
afterEach(() => {
33+
fs.rmSync(tmpHome, { recursive: true, force: true })
34+
})
35+
36+
describe('fingerprintServerConfig', () => {
37+
it('is deterministic for identical config', () => {
38+
const cfg: MCPServerConfig = { command: 'sh', args: ['-c', 'echo hi'] }
39+
expect(fingerprintServerConfig(cfg)).to.equal(fingerprintServerConfig({ ...cfg }))
40+
})
41+
42+
it('differs when command changes', () => {
43+
const a: MCPServerConfig = { command: 'sh', args: ['-c', 'echo hi'] }
44+
const b: MCPServerConfig = { command: 'bash', args: ['-c', 'echo hi'] }
45+
expect(fingerprintServerConfig(a)).to.not.equal(fingerprintServerConfig(b))
46+
})
47+
48+
it('differs when args change', () => {
49+
const a: MCPServerConfig = { command: 'sh', args: ['-c', 'echo hi'] }
50+
const b: MCPServerConfig = { command: 'sh', args: ['-c', 'echo bye'] }
51+
expect(fingerprintServerConfig(a)).to.not.equal(fingerprintServerConfig(b))
52+
})
53+
54+
it('differs when env changes', () => {
55+
const a: MCPServerConfig = { command: 'sh', args: [], env: { FOO: '1' } }
56+
const b: MCPServerConfig = { command: 'sh', args: [], env: { FOO: '2' } }
57+
expect(fingerprintServerConfig(a)).to.not.equal(fingerprintServerConfig(b))
58+
})
59+
60+
it('is stable regardless of env key order', () => {
61+
const a: MCPServerConfig = { command: 'sh', args: [], env: { A: '1', B: '2' } }
62+
const b: MCPServerConfig = { command: 'sh', args: [], env: { B: '2', A: '1' } }
63+
expect(fingerprintServerConfig(a)).to.equal(fingerprintServerConfig(b))
64+
})
65+
66+
it('differs when url changes', () => {
67+
const a: MCPServerConfig = { url: 'https://a.example' }
68+
const b: MCPServerConfig = { url: 'https://b.example' }
69+
expect(fingerprintServerConfig(a)).to.not.equal(fingerprintServerConfig(b))
70+
})
71+
})
72+
73+
describe('fingerprintWorkspace', () => {
74+
it('is keyed on the directory of the config, not the filename', () => {
75+
const a = fingerprintWorkspace('/foo/bar/.amazonq/mcp.json')
76+
const b = fingerprintWorkspace('/foo/bar/.amazonq/agents/default.json')
77+
// both live under /foo/bar/.amazonq's parent-dir once; path.dirname differs though
78+
expect(a).to.not.equal(b)
79+
})
80+
81+
it('is deterministic for the same path', () => {
82+
const p = '/foo/bar/.amazonq/mcp.json'
83+
expect(fingerprintWorkspace(p)).to.equal(fingerprintWorkspace(p))
84+
})
85+
})
86+
87+
describe('hasApproval / recordApproval', () => {
88+
const cfg: MCPServerConfig = { command: 'sh', args: ['-c', 'echo ok'] }
89+
const configPath = '/tmp/ws-a/.amazonq/mcp.json'
90+
91+
it('returns false when store is empty', async () => {
92+
expect(await hasApproval(workspace, logger, 'poc', cfg, configPath)).to.be.false
93+
})
94+
95+
it('records and finds an approval for same (name, config, workspace)', async () => {
96+
await recordApproval(workspace, logger, 'poc', cfg, configPath)
97+
expect(await hasApproval(workspace, logger, 'poc', cfg, configPath)).to.be.true
98+
})
99+
100+
it('does not match when workspace path differs', async () => {
101+
await recordApproval(workspace, logger, 'poc', cfg, '/tmp/ws-a/.amazonq/mcp.json')
102+
expect(await hasApproval(workspace, logger, 'poc', cfg, '/tmp/ws-b/.amazonq/mcp.json')).to.be.false
103+
})
104+
105+
it('does not match when command changes (fingerprint invalidates)', async () => {
106+
await recordApproval(workspace, logger, 'poc', cfg, configPath)
107+
const mutated: MCPServerConfig = { command: 'sh', args: ['-c', 'curl evil'] }
108+
expect(await hasApproval(workspace, logger, 'poc', mutated, configPath)).to.be.false
109+
})
110+
111+
it('does not match when server name differs', async () => {
112+
await recordApproval(workspace, logger, 'poc', cfg, configPath)
113+
expect(await hasApproval(workspace, logger, 'other', cfg, configPath)).to.be.false
114+
})
115+
116+
it('dedupes repeated approvals for the same key', async () => {
117+
await recordApproval(workspace, logger, 'poc', cfg, configPath)
118+
await recordApproval(workspace, logger, 'poc', cfg, configPath)
119+
const stored = JSON.parse(
120+
fs.readFileSync(path.join(tmpHome, '.aws', 'amazonq', 'mcp-approvals.json')).toString()
121+
)
122+
expect(stored.approvals).to.have.lengthOf(1)
123+
})
124+
125+
it('evicts stale entry when config changes for same server and workspace', async () => {
126+
await recordApproval(workspace, logger, 'poc', cfg, configPath)
127+
const mutated: MCPServerConfig = { command: 'sh', args: ['-c', 'echo changed'] }
128+
await recordApproval(workspace, logger, 'poc', mutated, configPath)
129+
const stored = JSON.parse(
130+
fs.readFileSync(path.join(tmpHome, '.aws', 'amazonq', 'mcp-approvals.json')).toString()
131+
)
132+
// Should have exactly 1 entry — the old fingerprint was evicted
133+
expect(stored.approvals).to.have.lengthOf(1)
134+
expect(stored.approvals[0].fingerprint).to.equal(fingerprintServerConfig(mutated))
135+
})
136+
137+
it('ignores a store with unrecognized version', async () => {
138+
const storeDir = path.join(tmpHome, '.aws', 'amazonq')
139+
fs.mkdirSync(storeDir, { recursive: true })
140+
fs.writeFileSync(path.join(storeDir, 'mcp-approvals.json'), JSON.stringify({ version: 999, approvals: [] }))
141+
// record should still work (overwrites with v1)
142+
await recordApproval(workspace, logger, 'poc', cfg, configPath)
143+
expect(await hasApproval(workspace, logger, 'poc', cfg, configPath)).to.be.true
144+
})
145+
146+
it('treats a malformed store as empty', async () => {
147+
const storeDir = path.join(tmpHome, '.aws', 'amazonq')
148+
fs.mkdirSync(storeDir, { recursive: true })
149+
fs.writeFileSync(path.join(storeDir, 'mcp-approvals.json'), 'not json')
150+
expect(await hasApproval(workspace, logger, 'poc', cfg, configPath)).to.be.false
151+
})
152+
})
153+
})
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/**
2+
* Copyright Amazon.com, Inc. or its affiliates.
3+
* All Rights Reserved. SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import { createHash } from 'crypto'
7+
import * as path from 'path'
8+
import type { Workspace, Logging } from '@aws/language-server-runtimes/server-interface'
9+
import type { MCPServerConfig } from './mcpTypes'
10+
11+
const APPROVALS_FILE = 'mcp-approvals.json'
12+
const STORE_VERSION = 1
13+
14+
interface Approval {
15+
serverName: string
16+
fingerprint: string
17+
workspaceHash: string
18+
approvedAt: string
19+
}
20+
21+
interface ApprovalStore {
22+
version: number
23+
approvals: Approval[]
24+
}
25+
26+
/**
27+
* SHA-256 of a canonical JSON form of the server's execution-relevant fields.
28+
* Any change to command/args/env/url yields a new fingerprint, invalidating
29+
* prior approvals — so mutation of the config re-prompts.
30+
*/
31+
export function fingerprintServerConfig(cfg: MCPServerConfig): string {
32+
const canonical = {
33+
command: cfg.command ?? null,
34+
args: cfg.args ?? [],
35+
env: cfg.env ? Object.fromEntries(Object.entries(cfg.env).sort(([a], [b]) => a.localeCompare(b))) : {},
36+
url: cfg.url ?? null,
37+
}
38+
return 'sha256:' + createHash('sha256').update(JSON.stringify(canonical)).digest('hex')
39+
}
40+
41+
/** Hash of the workspace path so approval is scoped to (workspace, config).
42+
* Normalizes the path to forward slashes for cross-platform consistency. */
43+
export function fingerprintWorkspace(configPath: string): string {
44+
const normalized = path.resolve(path.dirname(configPath)).replace(/\\/g, '/')
45+
return 'sha256:' + createHash('sha256').update(normalized).digest('hex')
46+
}
47+
48+
function getStorePath(workspace: Workspace): string {
49+
return path.join(workspace.fs.getUserHomeDir(), '.aws', 'amazonq', APPROVALS_FILE)
50+
}
51+
52+
async function readStore(workspace: Workspace, logging: Logging): Promise<ApprovalStore> {
53+
const file = getStorePath(workspace)
54+
try {
55+
if (!(await workspace.fs.exists(file))) {
56+
return { version: STORE_VERSION, approvals: [] }
57+
}
58+
const raw = (await workspace.fs.readFile(file)).toString()
59+
const parsed = JSON.parse(raw) as ApprovalStore
60+
if (parsed?.version !== STORE_VERSION || !Array.isArray(parsed.approvals)) {
61+
logging.warn(`MCP consent store: unrecognized format at ${file}, treating as empty`)
62+
return { version: STORE_VERSION, approvals: [] }
63+
}
64+
return parsed
65+
} catch (e: any) {
66+
logging.warn(`MCP consent store: failed to read ${file}: ${e?.message}`)
67+
return { version: STORE_VERSION, approvals: [] }
68+
}
69+
}
70+
71+
async function writeStore(workspace: Workspace, logging: Logging, store: ApprovalStore): Promise<void> {
72+
const file = getStorePath(workspace)
73+
try {
74+
await workspace.fs.mkdir(path.dirname(file), { recursive: true })
75+
await workspace.fs.writeFile(file, JSON.stringify(store, null, 2))
76+
} catch (e: any) {
77+
logging.warn(`MCP consent store: failed to write ${file}: ${e?.message}`)
78+
}
79+
}
80+
81+
export async function hasApproval(
82+
workspace: Workspace,
83+
logging: Logging,
84+
serverName: string,
85+
cfg: MCPServerConfig,
86+
configPath: string
87+
): Promise<boolean> {
88+
const store = await readStore(workspace, logging)
89+
const fp = fingerprintServerConfig(cfg)
90+
const wh = fingerprintWorkspace(configPath)
91+
return store.approvals.some(a => a.serverName === serverName && a.fingerprint === fp && a.workspaceHash === wh)
92+
}
93+
94+
export async function recordApproval(
95+
workspace: Workspace,
96+
logging: Logging,
97+
serverName: string,
98+
cfg: MCPServerConfig,
99+
configPath: string
100+
): Promise<void> {
101+
const store = await readStore(workspace, logging)
102+
const fp = fingerprintServerConfig(cfg)
103+
const wh = fingerprintWorkspace(configPath)
104+
// Replace any prior approval for the same (server, workspace) — this evicts
105+
// stale entries when the config changes (fingerprint differs).
106+
store.approvals = store.approvals.filter(a => !(a.serverName === serverName && a.workspaceHash === wh))
107+
store.approvals.push({
108+
serverName,
109+
fingerprint: fp,
110+
workspaceHash: wh,
111+
approvedAt: new Date().toISOString(),
112+
})
113+
await writeStore(workspace, logging, store)
114+
}

0 commit comments

Comments
 (0)