@open-mercato/ai-assistant 0.6.5-develop.5382.1.f542de69af → 0.6.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +12 -0
- package/dist/modules/ai_assistant/cli.js +14 -6
- package/dist/modules/ai_assistant/cli.js.map +2 -2
- package/dist/modules/ai_assistant/data/entities.js +4 -0
- package/dist/modules/ai_assistant/data/entities.js.map +2 -2
- package/dist/modules/ai_assistant/lib/attachment-parts.js +10 -3
- package/dist/modules/ai_assistant/lib/attachment-parts.js.map +2 -2
- package/dist/modules/ai_assistant/lib/client-factory.js +0 -8
- package/dist/modules/ai_assistant/lib/client-factory.js.map +2 -2
- package/dist/modules/ai_assistant/lib/codemode-tools.js +26 -0
- package/dist/modules/ai_assistant/lib/codemode-tools.js.map +2 -2
- package/dist/modules/ai_assistant/lib/generated-registry-loader.js +23 -3
- package/dist/modules/ai_assistant/lib/generated-registry-loader.js.map +2 -2
- package/dist/modules/ai_assistant/lib/mcp-client.js +2 -4
- package/dist/modules/ai_assistant/lib/mcp-client.js.map +2 -2
- package/dist/modules/ai_assistant/lib/mcp-dev-key-resolution.js +71 -0
- package/dist/modules/ai_assistant/lib/mcp-dev-key-resolution.js.map +7 -0
- package/dist/modules/ai_assistant/lib/mcp-dev-server.js +1 -30
- package/dist/modules/ai_assistant/lib/mcp-dev-server.js.map +2 -2
- package/dist/modules/ai_assistant/lib/mcp-server.js +29 -23
- package/dist/modules/ai_assistant/lib/mcp-server.js.map +2 -2
- package/dist/modules/ai_assistant/setup.js +9 -2
- package/dist/modules/ai_assistant/setup.js.map +2 -2
- package/package.json +11 -12
- package/src/modules/ai_assistant/__tests__/cli-mcp-serve-key.test.ts +63 -0
- package/src/modules/ai_assistant/__tests__/setup-schedules.test.ts +33 -0
- package/src/modules/ai_assistant/cli.ts +18 -5
- package/src/modules/ai_assistant/data/__tests__/schema-unique-indexes.test.ts +39 -0
- package/src/modules/ai_assistant/data/entities.ts +5 -0
- package/src/modules/ai_assistant/lib/__tests__/attachment-parts.test.ts +112 -0
- package/src/modules/ai_assistant/lib/__tests__/client-factory.test.ts +42 -0
- package/src/modules/ai_assistant/lib/__tests__/codemode-tools.test.ts +107 -0
- package/src/modules/ai_assistant/lib/__tests__/generated-registry-loader.test.ts +30 -3
- package/src/modules/ai_assistant/lib/__tests__/mcp-client.test.ts +20 -1
- package/src/modules/ai_assistant/lib/__tests__/mcp-dev-key-resolution.test.ts +92 -0
- package/src/modules/ai_assistant/lib/__tests__/mcp-server-auth-fail-closed.test.ts +133 -0
- package/src/modules/ai_assistant/lib/attachment-parts.ts +26 -5
- package/src/modules/ai_assistant/lib/client-factory.ts +3 -9
- package/src/modules/ai_assistant/lib/codemode-tools.ts +49 -0
- package/src/modules/ai_assistant/lib/generated-registry-loader.ts +35 -3
- package/src/modules/ai_assistant/lib/mcp-client.ts +9 -5
- package/src/modules/ai_assistant/lib/mcp-dev-key-resolution.ts +107 -0
- package/src/modules/ai_assistant/lib/mcp-dev-server.ts +1 -36
- package/src/modules/ai_assistant/lib/mcp-server.ts +47 -33
- package/src/modules/ai_assistant/lib/types.ts +7 -0
- package/src/modules/ai_assistant/setup.ts +11 -2
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { resolve } from 'node:path'
|
|
2
|
+
import { findProjectRoot, checkMcpConfigPermissions } from '../mcp-dev-key-resolution'
|
|
3
|
+
|
|
4
|
+
describe('mcp:dev API key resolution (#2671)', () => {
|
|
5
|
+
describe('findProjectRoot', () => {
|
|
6
|
+
const projectRoot = resolve('/home/dev/project')
|
|
7
|
+
const launchDir = resolve('/home/dev/project/packages/ai-assistant')
|
|
8
|
+
|
|
9
|
+
it('returns the nearest ancestor containing a root marker', () => {
|
|
10
|
+
const exists = (path: string) => path === resolve(projectRoot, '.git')
|
|
11
|
+
expect(findProjectRoot(launchDir, exists)).toBe(projectRoot)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('stops at the nearest marker and never picks a marker higher up the tree', () => {
|
|
15
|
+
// A marker exists both at the real project root and at a higher ancestor.
|
|
16
|
+
// Walking up from the launch dir must stop at the closest one, so an
|
|
17
|
+
// attacker planting a marker (and a sibling .mcp.json) higher up cannot
|
|
18
|
+
// shadow the real project root.
|
|
19
|
+
const higherAncestor = resolve('/home/dev')
|
|
20
|
+
const exists = (path: string) =>
|
|
21
|
+
path === resolve(projectRoot, '.git') || path === resolve(higherAncestor, '.git')
|
|
22
|
+
expect(findProjectRoot(launchDir, exists)).toBe(projectRoot)
|
|
23
|
+
expect(findProjectRoot(launchDir, exists)).not.toBe(higherAncestor)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('falls back to the start directory (never the filesystem root) when no marker is found', () => {
|
|
27
|
+
const exists = () => false
|
|
28
|
+
expect(findProjectRoot(launchDir, exists)).toBe(launchDir)
|
|
29
|
+
expect(findProjectRoot(launchDir, exists)).not.toBe(resolve('/'))
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('recognises any of the supported root markers', () => {
|
|
33
|
+
const exists = (path: string) => path === resolve(projectRoot, 'yarn.lock')
|
|
34
|
+
expect(findProjectRoot(launchDir, exists)).toBe(projectRoot)
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
describe('checkMcpConfigPermissions', () => {
|
|
39
|
+
const configPath = '/home/dev/project/.mcp.json'
|
|
40
|
+
|
|
41
|
+
it('refuses to read a config owned by another user', () => {
|
|
42
|
+
const warnings: string[] = []
|
|
43
|
+
const result = checkMcpConfigPermissions(
|
|
44
|
+
configPath,
|
|
45
|
+
{ uid: 4242, mode: 0o600 },
|
|
46
|
+
1000,
|
|
47
|
+
(message) => warnings.push(message),
|
|
48
|
+
)
|
|
49
|
+
expect(result.ok).toBe(false)
|
|
50
|
+
expect(result.reason).toContain('owned by uid 4242')
|
|
51
|
+
expect(warnings).toHaveLength(0)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('warns loudly but allows when the config is group/world accessible', () => {
|
|
55
|
+
const warnings: string[] = []
|
|
56
|
+
const result = checkMcpConfigPermissions(
|
|
57
|
+
configPath,
|
|
58
|
+
{ uid: 1000, mode: 0o644 },
|
|
59
|
+
1000,
|
|
60
|
+
(message) => warnings.push(message),
|
|
61
|
+
)
|
|
62
|
+
expect(result.ok).toBe(true)
|
|
63
|
+
expect(warnings).toHaveLength(1)
|
|
64
|
+
expect(warnings[0]).toContain('accessible to group/other')
|
|
65
|
+
expect(warnings[0]).toContain('chmod 600')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('accepts an owner-only config without warning', () => {
|
|
69
|
+
const warnings: string[] = []
|
|
70
|
+
const result = checkMcpConfigPermissions(
|
|
71
|
+
configPath,
|
|
72
|
+
{ uid: 1000, mode: 0o600 },
|
|
73
|
+
1000,
|
|
74
|
+
(message) => warnings.push(message),
|
|
75
|
+
)
|
|
76
|
+
expect(result.ok).toBe(true)
|
|
77
|
+
expect(warnings).toHaveLength(0)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('skips ownership/permission checks when uid is unavailable (non-POSIX)', () => {
|
|
81
|
+
const warnings: string[] = []
|
|
82
|
+
const result = checkMcpConfigPermissions(
|
|
83
|
+
configPath,
|
|
84
|
+
{ uid: 4242, mode: 0o644 },
|
|
85
|
+
null,
|
|
86
|
+
(message) => warnings.push(message),
|
|
87
|
+
)
|
|
88
|
+
expect(result.ok).toBe(true)
|
|
89
|
+
expect(warnings).toHaveLength(0)
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
})
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { createMcpServer } from '../mcp-server'
|
|
2
|
+
import type { McpServerOptions } from '../types'
|
|
3
|
+
|
|
4
|
+
jest.mock('@modelcontextprotocol/sdk/server/index.js', () => ({
|
|
5
|
+
Server: class {
|
|
6
|
+
setRequestHandler() {}
|
|
7
|
+
},
|
|
8
|
+
}))
|
|
9
|
+
jest.mock('@modelcontextprotocol/sdk/server/stdio.js', () => ({
|
|
10
|
+
StdioServerTransport: class {},
|
|
11
|
+
}))
|
|
12
|
+
jest.mock('@modelcontextprotocol/sdk/types.js', () => ({
|
|
13
|
+
ListToolsRequestSchema: {},
|
|
14
|
+
CallToolRequestSchema: {},
|
|
15
|
+
}))
|
|
16
|
+
jest.mock('../tool-registry', () => ({
|
|
17
|
+
getToolRegistry: () => ({
|
|
18
|
+
getTools: () => new Map(),
|
|
19
|
+
listToolNames: () => [],
|
|
20
|
+
}),
|
|
21
|
+
}))
|
|
22
|
+
jest.mock('../tool-executor', () => ({ executeTool: jest.fn() }))
|
|
23
|
+
jest.mock('../tool-loader', () => ({
|
|
24
|
+
loadAllModuleTools: jest.fn(),
|
|
25
|
+
indexToolsForSearch: jest.fn(),
|
|
26
|
+
}))
|
|
27
|
+
|
|
28
|
+
const authenticateMcpRequest = jest.fn()
|
|
29
|
+
jest.mock('../auth', () => ({
|
|
30
|
+
authenticateMcpRequest: (...args: unknown[]) => authenticateMcpRequest(...args),
|
|
31
|
+
hasRequiredFeatures: jest.fn(() => true),
|
|
32
|
+
}))
|
|
33
|
+
|
|
34
|
+
const loadAcl = jest.fn()
|
|
35
|
+
|
|
36
|
+
function makeContainer() {
|
|
37
|
+
return {
|
|
38
|
+
resolve: (name: string) => {
|
|
39
|
+
if (name === 'rbacService') return { loadAcl }
|
|
40
|
+
return {}
|
|
41
|
+
},
|
|
42
|
+
} as unknown as McpServerOptions['container']
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function baseOptions(overrides: Partial<McpServerOptions>): McpServerOptions {
|
|
46
|
+
return {
|
|
47
|
+
config: { name: 'test-mcp', version: '0.0.0', debug: false },
|
|
48
|
+
container: makeContainer(),
|
|
49
|
+
...overrides,
|
|
50
|
+
} as McpServerOptions
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe('issue #2673 — MCP stdio server must fail closed without auth', () => {
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
authenticateMcpRequest.mockReset()
|
|
56
|
+
loadAcl.mockReset()
|
|
57
|
+
loadAcl.mockResolvedValue({ isSuperAdmin: false, features: ['ai_assistant.view'] })
|
|
58
|
+
jest.spyOn(console, 'error').mockImplementation(() => {})
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
afterEach(() => {
|
|
62
|
+
jest.restoreAllMocks()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('throws when neither apiKeySecret nor context is supplied', async () => {
|
|
66
|
+
await expect(createMcpServer(baseOptions({}))).rejects.toThrow(
|
|
67
|
+
/refused to start: no authentication/i,
|
|
68
|
+
)
|
|
69
|
+
expect(loadAcl).not.toHaveBeenCalled()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('throws when context lacks a userId (the mcp:serve --tenant-without-user path)', async () => {
|
|
73
|
+
await expect(
|
|
74
|
+
createMcpServer(
|
|
75
|
+
baseOptions({ context: { tenantId: 't1', organizationId: 'o1', userId: null } }),
|
|
76
|
+
),
|
|
77
|
+
).rejects.toThrow(/refused to start: no authentication/i)
|
|
78
|
+
expect(loadAcl).not.toHaveBeenCalled()
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('treats an empty-string apiKeySecret as missing and fails closed', async () => {
|
|
82
|
+
await expect(createMcpServer(baseOptions({ apiKeySecret: '' }))).rejects.toThrow(
|
|
83
|
+
/refused to start: no authentication/i,
|
|
84
|
+
)
|
|
85
|
+
expect(authenticateMcpRequest).not.toHaveBeenCalled()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('treats a whitespace-only apiKeySecret as missing and fails closed', async () => {
|
|
89
|
+
await expect(createMcpServer(baseOptions({ apiKeySecret: ' ' }))).rejects.toThrow(
|
|
90
|
+
/refused to start: no authentication/i,
|
|
91
|
+
)
|
|
92
|
+
expect(authenticateMcpRequest).not.toHaveBeenCalled()
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('loads the real user ACL when context carries a non-empty userId', async () => {
|
|
96
|
+
const server = await createMcpServer(
|
|
97
|
+
baseOptions({ context: { tenantId: 't1', organizationId: 'o1', userId: 'u1' } }),
|
|
98
|
+
)
|
|
99
|
+
expect(server).toBeDefined()
|
|
100
|
+
expect(loadAcl).toHaveBeenCalledWith('u1', { tenantId: 't1', organizationId: 'o1' })
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('allows unauthenticated superadmin only behind the explicit opt-in, without loading an ACL', async () => {
|
|
104
|
+
const server = await createMcpServer(
|
|
105
|
+
baseOptions({
|
|
106
|
+
context: { tenantId: 't1', organizationId: 'o1', userId: null },
|
|
107
|
+
allowUnauthenticatedSuperadmin: true,
|
|
108
|
+
}),
|
|
109
|
+
)
|
|
110
|
+
expect(server).toBeDefined()
|
|
111
|
+
expect(loadAcl).not.toHaveBeenCalled()
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('allows fully unauthenticated superadmin behind the opt-in with no context', async () => {
|
|
115
|
+
const server = await createMcpServer(baseOptions({ allowUnauthenticatedSuperadmin: true }))
|
|
116
|
+
expect(server).toBeDefined()
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('still authenticates via a valid apiKeySecret', async () => {
|
|
120
|
+
authenticateMcpRequest.mockResolvedValue({
|
|
121
|
+
success: true,
|
|
122
|
+
tenantId: 't1',
|
|
123
|
+
organizationId: 'o1',
|
|
124
|
+
userId: 'u1',
|
|
125
|
+
features: ['ai_assistant.view'],
|
|
126
|
+
isSuperAdmin: false,
|
|
127
|
+
keyName: 'test-key',
|
|
128
|
+
})
|
|
129
|
+
const server = await createMcpServer(baseOptions({ apiKeySecret: 'omk_valid.secret' }))
|
|
130
|
+
expect(server).toBeDefined()
|
|
131
|
+
expect(authenticateMcpRequest).toHaveBeenCalledWith('omk_valid.secret', expect.anything())
|
|
132
|
+
})
|
|
133
|
+
})
|
|
@@ -128,10 +128,23 @@ async function loadAttachmentRow(
|
|
|
128
128
|
// core package owns the MikroORM metadata and is the only place tests would
|
|
129
129
|
// need to bootstrap for real DB access.
|
|
130
130
|
const { Attachment } = await import('@open-mercato/core/modules/attachments/data/entities')
|
|
131
|
+
// Tenant isolation is enforced in the SQL WHERE clause, not just the JS
|
|
132
|
+
// post-filter below: the decryption scope (5th arg) only drives field
|
|
133
|
+
// decryption, so omitting tenant/org from `where` would let `em.findOne`
|
|
134
|
+
// return a row from another tenant. Super-admins bypass the scope. Org-scoped
|
|
135
|
+
// callers also constrain by org; tenant-wide callers (organizationId === null)
|
|
136
|
+
// may read any org within their tenant.
|
|
137
|
+
const where: Record<string, unknown> = { id: attachmentId }
|
|
138
|
+
if (!authContext.isSuperAdmin) {
|
|
139
|
+
where.tenantId = authContext.tenantId
|
|
140
|
+
if (authContext.organizationId != null) {
|
|
141
|
+
where.organizationId = authContext.organizationId
|
|
142
|
+
}
|
|
143
|
+
}
|
|
131
144
|
const record = await findOneWithDecryption(
|
|
132
145
|
em,
|
|
133
146
|
Attachment as never,
|
|
134
|
-
|
|
147
|
+
where as never,
|
|
135
148
|
undefined,
|
|
136
149
|
{
|
|
137
150
|
tenantId: authContext.tenantId,
|
|
@@ -157,10 +170,18 @@ async function loadAttachmentRow(
|
|
|
157
170
|
|
|
158
171
|
function rowBelongsToCaller(row: AttachmentRow, authContext: AiChatRequestContext): boolean {
|
|
159
172
|
if (authContext.isSuperAdmin) return true
|
|
160
|
-
// Tenant scope:
|
|
161
|
-
|
|
162
|
-
//
|
|
163
|
-
|
|
173
|
+
// Tenant scope: fail closed. The record MUST carry the caller's tenant.
|
|
174
|
+
// A null `tenant_id` (a supported "global"/unscoped attachment state) is NOT
|
|
175
|
+
// accessible through the AI path: it has no `partition.isPublic` gate, so a
|
|
176
|
+
// truthiness short-circuit here would leak the bytes / extracted text into a
|
|
177
|
+
// different tenant's LLM context (cross-tenant IDOR, issue #2663). Requiring
|
|
178
|
+
// strict equality also rejects a non-super-admin caller with a null tenant.
|
|
179
|
+
if (authContext.tenantId == null || row.tenantId !== authContext.tenantId) return false
|
|
180
|
+
// Organization scope: when the caller is org-scoped, the record MUST match
|
|
181
|
+
// that organization (this also rejects null-org rows for an org-scoped
|
|
182
|
+
// caller). Tenant-wide callers (organizationId === null) may read any org
|
|
183
|
+
// within their tenant.
|
|
184
|
+
if (authContext.organizationId != null && row.organizationId !== authContext.organizationId) return false
|
|
164
185
|
return true
|
|
165
186
|
}
|
|
166
187
|
|
|
@@ -93,16 +93,10 @@ export async function createMcpClient(options: CreateClientOptions): Promise<Mcp
|
|
|
93
93
|
|
|
94
94
|
if (options.stdioArgs) {
|
|
95
95
|
stdioOptions.args = options.stdioArgs
|
|
96
|
-
} else {
|
|
97
|
-
// Default args include the API key
|
|
98
|
-
stdioOptions.args = [
|
|
99
|
-
'mercato',
|
|
100
|
-
'ai_assistant',
|
|
101
|
-
'mcp:serve',
|
|
102
|
-
'--api-key',
|
|
103
|
-
apiKeySecret,
|
|
104
|
-
]
|
|
105
96
|
}
|
|
97
|
+
// Otherwise let McpClient.connectStdio apply its default args. The API key
|
|
98
|
+
// is delivered to the child via the OPEN_MERCATO_API_KEY env var, never on
|
|
99
|
+
// argv — command-line arguments are world-readable (ps / /proc/<pid>/cmdline).
|
|
106
100
|
|
|
107
101
|
if (options.cwd) {
|
|
108
102
|
stdioOptions.cwd = options.cwd
|
|
@@ -883,6 +883,15 @@ export async function authorizeCodeModeApiRequest(
|
|
|
883
883
|
path: string
|
|
884
884
|
): Promise<CodeModeApiAuthorization> {
|
|
885
885
|
const normalizedMethod = method.toUpperCase()
|
|
886
|
+
|
|
887
|
+
if (isUnsafeApiRequestPath(path)) {
|
|
888
|
+
return {
|
|
889
|
+
allowed: false,
|
|
890
|
+
statusCode: 403,
|
|
891
|
+
error: `Code Mode rejected unsafe API path: ${normalizedMethod} ${path}`,
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
886
895
|
const normalizedPath = normalizeApiRequestPath(path)
|
|
887
896
|
const endpoint = await findCodeModeApiEndpoint(normalizedMethod, normalizedPath)
|
|
888
897
|
|
|
@@ -979,6 +988,46 @@ function normalizeApiRequestPath(path: string): string {
|
|
|
979
988
|
return normalizedPath
|
|
980
989
|
}
|
|
981
990
|
|
|
991
|
+
const SINGLE_DOT_SEGMENTS = new Set(['.', '%2e'])
|
|
992
|
+
const DOUBLE_DOT_SEGMENTS = new Set(['..', '.%2e', '%2e.', '%2e%2e'])
|
|
993
|
+
|
|
994
|
+
/**
|
|
995
|
+
* Rejects request paths that the WHATWG URL parser would rewrite before the
|
|
996
|
+
* actual fetch (`..`/`.` path segments — including their percent-encoded forms
|
|
997
|
+
* — backslashes, and percent-encoded separators). Code Mode authorizes the
|
|
998
|
+
* literal path it was given, but `new URL()` collapses dot segments and
|
|
999
|
+
* normalizes backslashes for http(s) URLs, so without this guard the wire
|
|
1000
|
+
* request can resolve to a different endpoint than the one that was authorized.
|
|
1001
|
+
*/
|
|
1002
|
+
export function isUnsafeApiRequestPath(path: string): boolean {
|
|
1003
|
+
const [rawPath] = String(path ?? '').split('?')
|
|
1004
|
+
|
|
1005
|
+
// The WHATWG URL parser strips ASCII tab/newline/carriage-return from the URL
|
|
1006
|
+
// before parsing, so a smuggled `.<TAB>.` segment collapses to `..` on the
|
|
1007
|
+
// wire even though the literal segment never equals a dot segment here. Raw
|
|
1008
|
+
// control characters never appear in legitimate REST paths, so reject them.
|
|
1009
|
+
if (/[\u0000-\u001f]/.test(rawPath)) {
|
|
1010
|
+
return true
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
// http(s) URLs treat backslashes as path separators, so they can smuggle
|
|
1014
|
+
// separators past the segment-based authorizer.
|
|
1015
|
+
if (rawPath.includes('\\')) {
|
|
1016
|
+
return true
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
// Percent-encoded separators never appear in legitimate REST paths and let
|
|
1020
|
+
// the literal-'/' segment split desync from the parsed request URL.
|
|
1021
|
+
if (/%2f/i.test(rawPath) || /%5c/i.test(rawPath)) {
|
|
1022
|
+
return true
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
return rawPath.split('/').some((segment) => {
|
|
1026
|
+
const lowered = segment.toLowerCase()
|
|
1027
|
+
return SINGLE_DOT_SEGMENTS.has(lowered) || DOUBLE_DOT_SEGMENTS.has(lowered)
|
|
1028
|
+
})
|
|
1029
|
+
}
|
|
1030
|
+
|
|
982
1031
|
function isPathParameterSegment(segment: string): boolean {
|
|
983
1032
|
return (
|
|
984
1033
|
(segment.startsWith('{') && segment.endsWith('}')) ||
|
|
@@ -95,6 +95,38 @@ export async function compileAndImportGenerated(tsPath: string): Promise<Record<
|
|
|
95
95
|
return (await import(pathToFileURL(jsPath).href)) as Record<string, unknown>
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
const UNSAFE_JS_STRING_CHAR_ESCAPES: Record<number, string> = {
|
|
99
|
+
0x3c: '\\u003C', // < — HTML/script-tag breakout
|
|
100
|
+
0x3e: '\\u003E', // > — HTML/script-tag breakout
|
|
101
|
+
0x2028: '\\u2028', // line separator — string content but a statement terminator pre-ES2019
|
|
102
|
+
0x2029: '\\u2029', // paragraph separator — same
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Escape characters that `JSON.stringify` leaves intact but which can still
|
|
107
|
+
* break out of (or alter the meaning of) the JavaScript string literal that the
|
|
108
|
+
* stringified value is embedded into — notably `<`/`>` (HTML/script-tag
|
|
109
|
+
* breakout) and the U+2028 / U+2029 line separators (valid string content but
|
|
110
|
+
* statement terminators in pre-ES2019 parsers). Apply this on top of
|
|
111
|
+
* `JSON.stringify` so the emitted import source stays well-formed regardless of
|
|
112
|
+
* the resolved path. Exported for unit testing.
|
|
113
|
+
*/
|
|
114
|
+
export function escapeUnsafeJsStringChars(value: string): string {
|
|
115
|
+
return value.replace(
|
|
116
|
+
/[<>\u2028\u2029]/g,
|
|
117
|
+
(char) => UNSAFE_JS_STRING_CHAR_ESCAPES[char.charCodeAt(0)],
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Stringify a resolved path into a JavaScript string literal that is safe to
|
|
123
|
+
* embed in generated source: `JSON.stringify` handles quoting/standard escapes,
|
|
124
|
+
* and `escapeUnsafeJsStringChars` neutralizes the characters it leaves intact.
|
|
125
|
+
*/
|
|
126
|
+
function toSafeJsStringLiteral(value: string): string {
|
|
127
|
+
return escapeUnsafeJsStringChars(JSON.stringify(value))
|
|
128
|
+
}
|
|
129
|
+
|
|
98
130
|
/**
|
|
99
131
|
* Rewrite `@/...` path-alias imports (both `from "@/x"` and dynamic
|
|
100
132
|
* `import("@/x")`) in generated-registry source to absolute `file://` URLs
|
|
@@ -110,14 +142,14 @@ export function rewriteGeneratedAliasImports(source: string, appRoot: string): s
|
|
|
110
142
|
: fs.existsSync(target + '.ts')
|
|
111
143
|
? target + '.ts'
|
|
112
144
|
: target
|
|
113
|
-
return pathToFileURL(candidate).href
|
|
145
|
+
return toSafeJsStringLiteral(pathToFileURL(candidate).href)
|
|
114
146
|
}
|
|
115
147
|
return source
|
|
116
148
|
.replace(/from\s+["']@\/([^"']+)["']/g, (_match, relativePath: string) => {
|
|
117
|
-
return `from ${
|
|
149
|
+
return `from ${resolveAlias(relativePath)}`
|
|
118
150
|
})
|
|
119
151
|
.replace(/import\s*\(\s*["']@\/([^"']+)["']\s*\)/g, (_match, relativePath: string) => {
|
|
120
|
-
return `import(${
|
|
152
|
+
return `import(${resolveAlias(relativePath)})`
|
|
121
153
|
})
|
|
122
154
|
}
|
|
123
155
|
|
|
@@ -8,11 +8,15 @@ import type { McpClientInterface, ToolInfo, ToolResult } from './types'
|
|
|
8
8
|
*/
|
|
9
9
|
export type StdioClientOptions = {
|
|
10
10
|
transport: 'stdio'
|
|
11
|
-
/**
|
|
11
|
+
/**
|
|
12
|
+
* API key secret. Delivered to the spawned server via the
|
|
13
|
+
* `OPEN_MERCATO_API_KEY` environment variable, never as a command-line
|
|
14
|
+
* argument (argv is world-readable via `ps`/`/proc/<pid>/cmdline`).
|
|
15
|
+
*/
|
|
12
16
|
apiKeySecret: string
|
|
13
17
|
/** Command to run (default: 'yarn') */
|
|
14
18
|
command?: string
|
|
15
|
-
/** Arguments for the command (default: mercato mcp:serve
|
|
19
|
+
/** Arguments for the command (default: mercato ai_assistant mcp:serve, no secret on argv) */
|
|
16
20
|
args?: string[]
|
|
17
21
|
/** Working directory (default: process.cwd()) */
|
|
18
22
|
cwd?: string
|
|
@@ -72,12 +76,12 @@ export class McpClient implements McpClientInterface {
|
|
|
72
76
|
*/
|
|
73
77
|
private static async connectStdio(options: StdioClientOptions): Promise<McpClient> {
|
|
74
78
|
const command = options.command ?? 'yarn'
|
|
79
|
+
// The API key is passed via OPEN_MERCATO_API_KEY in the child env (below),
|
|
80
|
+
// never on argv — command-line arguments are readable by any local user.
|
|
75
81
|
const args = options.args ?? [
|
|
76
82
|
'mercato',
|
|
77
83
|
'ai_assistant',
|
|
78
84
|
'mcp:serve',
|
|
79
|
-
'--api-key',
|
|
80
|
-
options.apiKeySecret,
|
|
81
85
|
]
|
|
82
86
|
const cwd = options.cwd ?? process.cwd()
|
|
83
87
|
|
|
@@ -85,7 +89,7 @@ export class McpClient implements McpClientInterface {
|
|
|
85
89
|
command,
|
|
86
90
|
args,
|
|
87
91
|
cwd,
|
|
88
|
-
env: process.env as Record<string, string>,
|
|
92
|
+
env: { ...process.env, OPEN_MERCATO_API_KEY: options.apiKeySecret } as Record<string, string>,
|
|
89
93
|
stderr: 'pipe',
|
|
90
94
|
})
|
|
91
95
|
transport.stderr?.on('data', (data) => {
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { resolve, dirname } from 'node:path'
|
|
2
|
+
|
|
3
|
+
const MCP_CONFIG_FILENAME = '.mcp.json'
|
|
4
|
+
|
|
5
|
+
// Markers that identify a project/repo root. The lookup stops at the first
|
|
6
|
+
// ancestor that has one of these, so the secret is only ever read from the
|
|
7
|
+
// project root — never from an unbounded walk up to the filesystem root.
|
|
8
|
+
const PROJECT_ROOT_MARKERS = ['.git', 'yarn.lock', 'pnpm-lock.yaml', 'package-lock.json'] as const
|
|
9
|
+
|
|
10
|
+
const log = (message: string, ...args: unknown[]) => {
|
|
11
|
+
console.error(`[MCP Dev] ${message}`, ...args)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type PermissionStat = { uid: number; mode: number }
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Resolve the project root by walking up from `startDir` to the nearest
|
|
18
|
+
* ancestor containing a project-root marker. Falls back to `startDir` (never
|
|
19
|
+
* the filesystem root) when no marker is found, so a planted `.mcp.json` in a
|
|
20
|
+
* writable ancestor such as `/tmp` can never be picked up.
|
|
21
|
+
*/
|
|
22
|
+
export function findProjectRoot(startDir: string, exists: (path: string) => boolean): string {
|
|
23
|
+
let dir = resolve(startDir)
|
|
24
|
+
while (true) {
|
|
25
|
+
for (const marker of PROJECT_ROOT_MARKERS) {
|
|
26
|
+
if (exists(resolve(dir, marker))) return dir
|
|
27
|
+
}
|
|
28
|
+
const parent = dirname(dir)
|
|
29
|
+
if (parent === dir) return resolve(startDir)
|
|
30
|
+
dir = parent
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Validate the ownership and permissions of `.mcp.json` before its secret is
|
|
36
|
+
* read. Refuses when the file is owned by another user (a planted/shadowed
|
|
37
|
+
* config) and warns loudly when it is accessible to group/other (the key is a
|
|
38
|
+
* live secret). POSIX-only: when `currentUid` is null (e.g. Windows) the checks
|
|
39
|
+
* are skipped.
|
|
40
|
+
*/
|
|
41
|
+
export function checkMcpConfigPermissions(
|
|
42
|
+
configPath: string,
|
|
43
|
+
fileStat: PermissionStat,
|
|
44
|
+
currentUid: number | null,
|
|
45
|
+
warn: (message: string) => void,
|
|
46
|
+
): { ok: boolean; reason?: string } {
|
|
47
|
+
if (currentUid === null) return { ok: true }
|
|
48
|
+
if (fileStat.uid !== currentUid) {
|
|
49
|
+
return {
|
|
50
|
+
ok: false,
|
|
51
|
+
reason: `refusing to read ${configPath}: it is owned by uid ${fileStat.uid}, not the current user (uid ${currentUid})`,
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if ((fileStat.mode & 0o077) !== 0) {
|
|
55
|
+
warn(
|
|
56
|
+
`${configPath} is accessible to group/other (mode ${(fileStat.mode & 0o777).toString(8)}); it contains a live API key — restrict it with: chmod 600 ${configPath}`,
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
return { ok: true }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Resolve the dev MCP server's API key.
|
|
64
|
+
*
|
|
65
|
+
* Priority: `OPEN_MERCATO_API_KEY` env var, then `headers.x-api-key` from a
|
|
66
|
+
* single `.mcp.json` read from the detected project root. The lookup is bounded
|
|
67
|
+
* to the project root (it never walks up to the filesystem root), and the file
|
|
68
|
+
* is ownership/permission-checked before its secret is read.
|
|
69
|
+
*/
|
|
70
|
+
export async function getApiKeyFromMcpJson(): Promise<string | undefined> {
|
|
71
|
+
const envKey = process.env.OPEN_MERCATO_API_KEY
|
|
72
|
+
if (envKey && envKey.trim().length > 0) {
|
|
73
|
+
return envKey.trim()
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const { readFile, stat } = await import('node:fs/promises')
|
|
77
|
+
const { existsSync } = await import('node:fs')
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const projectRoot = findProjectRoot(process.cwd(), existsSync)
|
|
81
|
+
const mcpJsonPath = resolve(projectRoot, MCP_CONFIG_FILENAME)
|
|
82
|
+
if (!existsSync(mcpJsonPath)) {
|
|
83
|
+
return undefined
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const fileStat = await stat(mcpJsonPath)
|
|
87
|
+
const currentUid = typeof process.getuid === 'function' ? process.getuid() : null
|
|
88
|
+
const permissionCheck = checkMcpConfigPermissions(
|
|
89
|
+
mcpJsonPath,
|
|
90
|
+
{ uid: fileStat.uid, mode: fileStat.mode },
|
|
91
|
+
currentUid,
|
|
92
|
+
(message) => log(`Warning: ${message}`),
|
|
93
|
+
)
|
|
94
|
+
if (!permissionCheck.ok) {
|
|
95
|
+
log(`Error: ${permissionCheck.reason}`)
|
|
96
|
+
return undefined
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const content = await readFile(mcpJsonPath, 'utf-8')
|
|
100
|
+
const config = JSON.parse(content)
|
|
101
|
+
const serverConfig = config?.mcpServers?.['open-mercato']
|
|
102
|
+
|
|
103
|
+
return serverConfig?.headers?.['x-api-key']
|
|
104
|
+
} catch {
|
|
105
|
+
return undefined
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -7,6 +7,7 @@ import { executeTool } from './tool-executor'
|
|
|
7
7
|
import { loadAllModuleTools, indexToolsForSearch } from './tool-loader'
|
|
8
8
|
import { authenticateMcpRequest, extractApiKeyFromHeaders, hasRequiredFeatures } from './auth'
|
|
9
9
|
import { jsonSchemaToZod } from './schema-utils'
|
|
10
|
+
import { getApiKeyFromMcpJson } from './mcp-dev-key-resolution'
|
|
10
11
|
import type { McpToolContext } from './types'
|
|
11
12
|
import type { SearchService } from '@open-mercato/search/service'
|
|
12
13
|
import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
|
|
@@ -17,42 +18,6 @@ const log = (message: string, ...args: unknown[]) => {
|
|
|
17
18
|
console.error(`[MCP Dev] ${message}`, ...args)
|
|
18
19
|
}
|
|
19
20
|
|
|
20
|
-
async function getApiKeyFromMcpJson(): Promise<string | undefined> {
|
|
21
|
-
const { readFile, access } = await import('node:fs/promises')
|
|
22
|
-
const { resolve, dirname } = await import('node:path')
|
|
23
|
-
|
|
24
|
-
// Search for .mcp.json starting from cwd and walking up to project root
|
|
25
|
-
const findMcpJson = async (startDir: string): Promise<string | null> => {
|
|
26
|
-
let dir = startDir
|
|
27
|
-
const root = resolve('/')
|
|
28
|
-
while (dir !== root) {
|
|
29
|
-
const candidate = resolve(dir, '.mcp.json')
|
|
30
|
-
try {
|
|
31
|
-
await access(candidate)
|
|
32
|
-
return candidate
|
|
33
|
-
} catch {
|
|
34
|
-
dir = dirname(dir)
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
return null
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
try {
|
|
41
|
-
const mcpJsonPath = await findMcpJson(process.cwd())
|
|
42
|
-
if (!mcpJsonPath) {
|
|
43
|
-
return undefined
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
const content = await readFile(mcpJsonPath, 'utf-8')
|
|
47
|
-
const config = JSON.parse(content)
|
|
48
|
-
const serverConfig = config?.mcpServers?.['open-mercato']
|
|
49
|
-
|
|
50
|
-
return serverConfig?.headers?.['x-api-key']
|
|
51
|
-
} catch {
|
|
52
|
-
return undefined
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
21
|
/**
|
|
57
22
|
* Maximum request body size (1MB).
|
|
58
23
|
*/
|