@open-mercato/ai-assistant 0.6.5-develop.5337.1.534b781eac → 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 +14 -2
- package/README.md +1 -1
- package/dist/modules/ai_assistant/ai-tools/meta-pack.js +3 -1
- package/dist/modules/ai_assistant/ai-tools/meta-pack.js.map +2 -2
- package/dist/modules/ai_assistant/api/mcp-key/route.js +95 -0
- package/dist/modules/ai_assistant/api/mcp-key/route.js.map +7 -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/components/McpConfigDialog.js +1 -1
- package/dist/modules/ai_assistant/components/McpConfigDialog.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/ai-api-operation-runner.js +3 -1
- package/dist/modules/ai_assistant/lib/ai-api-operation-runner.js.map +2 -2
- package/dist/modules/ai_assistant/lib/ai-overrides.js.map +1 -1
- package/dist/modules/ai_assistant/lib/ai-sdk.js.map +1 -1
- 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/chat-config.js.map +1 -1
- 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/llm-adapters/anthropic.js.map +1 -1
- package/dist/modules/ai_assistant/lib/llm-adapters/google.js.map +1 -1
- package/dist/modules/ai_assistant/lib/llm-adapters/openai.js.map +1 -1
- package/dist/modules/ai_assistant/lib/llm-bootstrap.js.map +1 -1
- 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/lib/openai-compatible-presets.js.map +1 -1
- package/dist/modules/ai_assistant/lib/task-plan-stream.js.map +1 -1
- 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/ai-tools/__tests__/meta-pack.test.ts +29 -0
- package/src/modules/ai_assistant/ai-tools/meta-pack.ts +7 -1
- package/src/modules/ai_assistant/api/mcp-key/__tests__/route.test.ts +144 -0
- package/src/modules/ai_assistant/api/mcp-key/route.ts +125 -0
- package/src/modules/ai_assistant/cli.ts +18 -5
- package/src/modules/ai_assistant/components/McpConfigDialog.tsx +5 -1
- 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__/ai-overrides.test.ts +1 -1
- 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/__tests__/task-plan-stream.test.ts +1 -1
- package/src/modules/ai_assistant/lib/ai-api-operation-runner.ts +9 -0
- package/src/modules/ai_assistant/lib/ai-overrides.ts +1 -1
- package/src/modules/ai_assistant/lib/ai-sdk.ts +1 -1
- package/src/modules/ai_assistant/lib/attachment-parts.ts +26 -5
- package/src/modules/ai_assistant/lib/chat-config.ts +1 -1
- 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/llm-adapters/anthropic.ts +1 -1
- package/src/modules/ai_assistant/lib/llm-adapters/google.ts +1 -1
- package/src/modules/ai_assistant/lib/llm-adapters/openai.ts +1 -1
- package/src/modules/ai_assistant/lib/llm-bootstrap.ts +1 -1
- 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/openai-compatible-presets.ts +1 -1
- package/src/modules/ai_assistant/lib/task-plan-stream.ts +1 -1
- package/src/modules/ai_assistant/lib/types.ts +7 -0
- package/src/modules/ai_assistant/setup.ts +11 -2
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for POST /api/ai_assistant/mcp-key.
|
|
3
|
+
*
|
|
4
|
+
* The route creates a persistent MCP API key that inherits the calling user's
|
|
5
|
+
* roles. These tests pin the security-critical behavior: the key carries
|
|
6
|
+
* exactly the caller's own roles (no escalation), is scoped to the caller's
|
|
7
|
+
* tenant/organization, and degrades gracefully when the caller has no tenant.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const authMock = jest.fn()
|
|
11
|
+
const createRequestContainerMock = jest.fn()
|
|
12
|
+
const createApiKeyMock = jest.fn()
|
|
13
|
+
const findWithDecryptionMock = jest.fn()
|
|
14
|
+
|
|
15
|
+
jest.mock('@open-mercato/shared/lib/auth/server', () => ({
|
|
16
|
+
getAuthFromRequest: (...args: unknown[]) => authMock(...args),
|
|
17
|
+
}))
|
|
18
|
+
|
|
19
|
+
jest.mock('@open-mercato/shared/lib/di/container', () => ({
|
|
20
|
+
createRequestContainer: (...args: unknown[]) => createRequestContainerMock(...args),
|
|
21
|
+
}))
|
|
22
|
+
|
|
23
|
+
jest.mock('@open-mercato/core/modules/api_keys/services/apiKeyService', () => ({
|
|
24
|
+
createApiKey: (...args: unknown[]) => createApiKeyMock(...args),
|
|
25
|
+
}))
|
|
26
|
+
|
|
27
|
+
jest.mock('@open-mercato/shared/lib/encryption/find', () => ({
|
|
28
|
+
findWithDecryption: (...args: unknown[]) => findWithDecryptionMock(...args),
|
|
29
|
+
}))
|
|
30
|
+
|
|
31
|
+
jest.mock('@open-mercato/core/modules/auth/data/entities', () => ({
|
|
32
|
+
UserRole: class UserRole {},
|
|
33
|
+
}))
|
|
34
|
+
|
|
35
|
+
import { POST } from '../route'
|
|
36
|
+
|
|
37
|
+
function buildRequest(body: unknown): Request {
|
|
38
|
+
return new Request('http://localhost/api/ai_assistant/mcp-key', {
|
|
39
|
+
method: 'POST',
|
|
40
|
+
body: typeof body === 'string' ? body : JSON.stringify(body),
|
|
41
|
+
headers: { 'content-type': 'application/json' },
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe('POST /api/ai_assistant/mcp-key', () => {
|
|
46
|
+
let consoleErrorSpy: jest.SpyInstance
|
|
47
|
+
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
jest.clearAllMocks()
|
|
50
|
+
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
|
|
51
|
+
authMock.mockResolvedValue({ sub: 'user-1', tenantId: 'tenant-1', orgId: 'org-1' })
|
|
52
|
+
createRequestContainerMock.mockResolvedValue({
|
|
53
|
+
resolve: (name: string) => (name === 'em' ? {} : null),
|
|
54
|
+
})
|
|
55
|
+
findWithDecryptionMock.mockResolvedValue([
|
|
56
|
+
{ role: { id: 'role-a' } },
|
|
57
|
+
{ role: { id: 'role-b' } },
|
|
58
|
+
])
|
|
59
|
+
createApiKeyMock.mockResolvedValue({
|
|
60
|
+
record: {
|
|
61
|
+
id: 'key-1',
|
|
62
|
+
name: 'My MCP Key',
|
|
63
|
+
keyPrefix: 'omk_abc1234',
|
|
64
|
+
tenantId: 'tenant-1',
|
|
65
|
+
organizationId: 'org-1',
|
|
66
|
+
},
|
|
67
|
+
secret: 'omk_abc1234.deadbeef',
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
afterEach(() => {
|
|
72
|
+
consoleErrorSpy.mockRestore()
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('returns 401 when unauthenticated', async () => {
|
|
76
|
+
authMock.mockResolvedValueOnce(null)
|
|
77
|
+
|
|
78
|
+
const response = await POST(buildRequest({ name: 'My MCP Key' }) as any)
|
|
79
|
+
|
|
80
|
+
expect(response.status).toBe(401)
|
|
81
|
+
expect(createApiKeyMock).not.toHaveBeenCalled()
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('creates a key carrying exactly the caller\'s own roles (no escalation)', async () => {
|
|
85
|
+
const response = await POST(buildRequest({ name: 'My MCP Key' }) as any)
|
|
86
|
+
|
|
87
|
+
expect(response.status).toBe(200)
|
|
88
|
+
const json = (await response.json()) as Record<string, unknown>
|
|
89
|
+
expect(json.secret).toBe('omk_abc1234.deadbeef')
|
|
90
|
+
expect(json.roles).toEqual(['role-a', 'role-b'])
|
|
91
|
+
|
|
92
|
+
expect(createApiKeyMock).toHaveBeenCalledTimes(1)
|
|
93
|
+
const [, input] = createApiKeyMock.mock.calls[0]
|
|
94
|
+
expect(input.roles).toEqual(['role-a', 'role-b'])
|
|
95
|
+
expect(input.tenantId).toBe('tenant-1')
|
|
96
|
+
expect(input.organizationId).toBe('org-1')
|
|
97
|
+
expect(input.createdBy).toBe('user-1')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('scopes the role lookup to the caller\'s tenant', async () => {
|
|
101
|
+
await POST(buildRequest({}) as any)
|
|
102
|
+
|
|
103
|
+
expect(findWithDecryptionMock).toHaveBeenCalledTimes(1)
|
|
104
|
+
const [, , where, , scope] = findWithDecryptionMock.mock.calls[0]
|
|
105
|
+
expect(where.user).toBe('user-1')
|
|
106
|
+
expect(where.role).toEqual({ tenantId: 'tenant-1' })
|
|
107
|
+
expect(scope).toEqual({ tenantId: 'tenant-1', organizationId: null })
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('assigns no roles and skips the lookup when the caller has no tenant', async () => {
|
|
111
|
+
authMock.mockResolvedValueOnce({ sub: 'user-1', tenantId: null, orgId: null })
|
|
112
|
+
|
|
113
|
+
const response = await POST(buildRequest({}) as any)
|
|
114
|
+
|
|
115
|
+
expect(response.status).toBe(200)
|
|
116
|
+
expect(findWithDecryptionMock).not.toHaveBeenCalled()
|
|
117
|
+
const [, input] = createApiKeyMock.mock.calls[0]
|
|
118
|
+
expect(input.roles).toEqual([])
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('ignores malformed role links when resolving ids', async () => {
|
|
122
|
+
findWithDecryptionMock.mockResolvedValueOnce([
|
|
123
|
+
{ role: { id: 'role-a' } },
|
|
124
|
+
{ role: null },
|
|
125
|
+
{ role: { id: '' } },
|
|
126
|
+
{},
|
|
127
|
+
])
|
|
128
|
+
|
|
129
|
+
await POST(buildRequest({}) as any)
|
|
130
|
+
|
|
131
|
+
const [, input] = createApiKeyMock.mock.calls[0]
|
|
132
|
+
expect(input.roles).toEqual(['role-a'])
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('returns 500 with a generic message when key creation fails', async () => {
|
|
136
|
+
createApiKeyMock.mockRejectedValueOnce(new Error('db down'))
|
|
137
|
+
|
|
138
|
+
const response = await POST(buildRequest({}) as any)
|
|
139
|
+
|
|
140
|
+
expect(response.status).toBe(500)
|
|
141
|
+
const json = (await response.json()) as Record<string, unknown>
|
|
142
|
+
expect(json.error).toBe('Failed to create MCP API key')
|
|
143
|
+
})
|
|
144
|
+
})
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { NextResponse, type NextRequest } from 'next/server'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
4
|
+
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
5
|
+
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
6
|
+
import { createApiKey } from '@open-mercato/core/modules/api_keys/services/apiKeyService'
|
|
7
|
+
import { UserRole } from '@open-mercato/core/modules/auth/data/entities'
|
|
8
|
+
import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
9
|
+
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
10
|
+
|
|
11
|
+
export const metadata = {
|
|
12
|
+
POST: { requireAuth: true, requireFeatures: ['api_keys.create'] },
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Resolve the calling user's role ids for the active tenant. Mirrors the
|
|
17
|
+
* `session-key` route so the generated key carries exactly the caller's roles.
|
|
18
|
+
*/
|
|
19
|
+
async function getUserRoleIds(
|
|
20
|
+
em: EntityManager,
|
|
21
|
+
userId: string,
|
|
22
|
+
tenantId: string | null,
|
|
23
|
+
): Promise<string[]> {
|
|
24
|
+
if (!tenantId) return []
|
|
25
|
+
const links = await findWithDecryption(
|
|
26
|
+
em,
|
|
27
|
+
UserRole,
|
|
28
|
+
{ user: userId as any, role: { tenantId } } as any,
|
|
29
|
+
{ populate: ['role'] },
|
|
30
|
+
{ tenantId, organizationId: null },
|
|
31
|
+
)
|
|
32
|
+
const linkList = Array.isArray(links) ? links : []
|
|
33
|
+
return linkList
|
|
34
|
+
.map((link) => (link.role as any)?.id)
|
|
35
|
+
.filter((id): id is string => typeof id === 'string' && id.length > 0)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const bodySchema = z.object({
|
|
39
|
+
name: z.string().min(1).max(120).optional(),
|
|
40
|
+
description: z.string().max(1000).optional().nullable(),
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* POST /api/ai_assistant/mcp-key
|
|
45
|
+
*
|
|
46
|
+
* Creates a persistent MCP API key (`omk_...`) for use in `.mcp.json`. Unlike a
|
|
47
|
+
* raw `POST /api/api_keys/keys` call — which assigns no roles unless the client
|
|
48
|
+
* passes them — this endpoint resolves the **caller's own roles** server-side
|
|
49
|
+
* and attaches them to the key, so the key inherits the current user's ACL.
|
|
50
|
+
* The user cannot escalate: they only ever grant their own roles to their own
|
|
51
|
+
* key.
|
|
52
|
+
*/
|
|
53
|
+
export async function POST(req: NextRequest) {
|
|
54
|
+
const auth = await getAuthFromRequest(req)
|
|
55
|
+
if (!auth?.sub) {
|
|
56
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const raw = await req.json().catch(() => ({}))
|
|
61
|
+
const body = bodySchema.parse(raw ?? {})
|
|
62
|
+
|
|
63
|
+
const container = await createRequestContainer()
|
|
64
|
+
const em = container.resolve<EntityManager>('em')
|
|
65
|
+
|
|
66
|
+
const roleIds = await getUserRoleIds(em, auth.sub, auth.tenantId)
|
|
67
|
+
|
|
68
|
+
const { record, secret } = await createApiKey(em, {
|
|
69
|
+
name: body.name ?? `MCP Config - ${new Date().toISOString().slice(0, 10)}`,
|
|
70
|
+
description: body.description ?? 'Generated from AI Assistant settings for MCP client',
|
|
71
|
+
tenantId: auth.tenantId ?? null,
|
|
72
|
+
organizationId: auth.orgId ?? null,
|
|
73
|
+
roles: roleIds,
|
|
74
|
+
createdBy: auth.sub,
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
return NextResponse.json({
|
|
78
|
+
id: record.id,
|
|
79
|
+
name: record.name,
|
|
80
|
+
keyPrefix: record.keyPrefix,
|
|
81
|
+
secret,
|
|
82
|
+
tenantId: record.tenantId ?? null,
|
|
83
|
+
organizationId: record.organizationId ?? null,
|
|
84
|
+
roles: roleIds,
|
|
85
|
+
})
|
|
86
|
+
} catch (error) {
|
|
87
|
+
console.error('[MCP Key] Error:', error)
|
|
88
|
+
return NextResponse.json(
|
|
89
|
+
{ error: 'Failed to create MCP API key' },
|
|
90
|
+
{ status: 500 },
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const responseSchema = z.object({
|
|
96
|
+
id: z.string(),
|
|
97
|
+
name: z.string(),
|
|
98
|
+
keyPrefix: z.string(),
|
|
99
|
+
secret: z.string().describe('Full API key value (omk_...). Shown once.'),
|
|
100
|
+
tenantId: z.string().uuid().nullable(),
|
|
101
|
+
organizationId: z.string().uuid().nullable(),
|
|
102
|
+
roles: z.array(z.string()).describe('Role ids inherited from the calling user.'),
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
const errorSchema = z.object({ error: z.string() })
|
|
106
|
+
|
|
107
|
+
export const openApi: OpenApiRouteDoc = {
|
|
108
|
+
summary: 'Create MCP API key',
|
|
109
|
+
description:
|
|
110
|
+
'Creates a persistent MCP API key that inherits the calling user\'s roles, for use in an MCP client `.mcp.json`.',
|
|
111
|
+
methods: {
|
|
112
|
+
POST: {
|
|
113
|
+
summary: 'Generate MCP API key',
|
|
114
|
+
description:
|
|
115
|
+
'Generates a persistent `omk_` API key scoped to the calling user\'s tenant/organization and carrying the caller\'s own roles, so the key has the same ACL as the user.',
|
|
116
|
+
responses: [
|
|
117
|
+
{ status: 200, description: 'API key created', schema: responseSchema },
|
|
118
|
+
],
|
|
119
|
+
errors: [
|
|
120
|
+
{ status: 401, description: 'Unauthorized', schema: errorSchema },
|
|
121
|
+
{ status: 500, description: 'Failed to create MCP API key', schema: errorSchema },
|
|
122
|
+
],
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
}
|
|
@@ -47,30 +47,42 @@ const mcpServe: ModuleCli = {
|
|
|
47
47
|
command: 'mcp:serve',
|
|
48
48
|
async run(rest) {
|
|
49
49
|
const args = parseArgs(rest)
|
|
50
|
-
|
|
50
|
+
// Prefer the OPEN_MERCATO_API_KEY env var so the secret never has to be
|
|
51
|
+
// placed on the command line (argv is world-readable via ps / /proc).
|
|
52
|
+
// The --api-key flag stays supported for backward compatibility.
|
|
53
|
+
const apiKey = String(args['api-key'] ?? args.apiKey ?? '') || process.env.OPEN_MERCATO_API_KEY || null
|
|
51
54
|
const tenantId = String(args.tenant ?? args.tenantId ?? '') || null
|
|
52
55
|
const organizationId = String(args.org ?? args.organizationId ?? '') || null
|
|
53
56
|
const userId = String(args.user ?? args.userId ?? '') || null
|
|
54
57
|
const debug = args.debug === true || args.debug === 'true'
|
|
58
|
+
const allowUnauthenticatedSuperadmin =
|
|
59
|
+
args['allow-unauthenticated-superadmin'] === true ||
|
|
60
|
+
args['allow-unauthenticated-superadmin'] === 'true'
|
|
55
61
|
|
|
56
62
|
// Either API key or tenant is required
|
|
57
63
|
if (!apiKey && !tenantId) {
|
|
58
64
|
console.error('Usage: mercato ai_assistant mcp:serve [options]')
|
|
59
65
|
console.error('')
|
|
60
66
|
console.error('Authentication (choose one):')
|
|
61
|
-
console.error('
|
|
62
|
-
console.error(' --
|
|
67
|
+
console.error(' OPEN_MERCATO_API_KEY env API key secret (recommended — keeps the secret off argv)')
|
|
68
|
+
console.error(' --api-key <secret> API key secret for authentication (visible in process listings)')
|
|
69
|
+
console.error(' --tenant <id> Tenant ID (for manual context)')
|
|
63
70
|
console.error('')
|
|
64
71
|
console.error('Options (with --tenant):')
|
|
65
72
|
console.error(' --org <id> Organization ID (optional)')
|
|
66
|
-
console.error(' --user <id> User ID for ACL (
|
|
73
|
+
console.error(' --user <id> User ID for ACL (required unless --allow-unauthenticated-superadmin is set)')
|
|
67
74
|
console.error('')
|
|
68
75
|
console.error('Common options:')
|
|
69
76
|
console.error(' --debug Enable debug logging')
|
|
77
|
+
console.error(' --allow-unauthenticated-superadmin')
|
|
78
|
+
console.error(' DEV/TEST ONLY: run as superadmin with no per-user ACL when')
|
|
79
|
+
console.error(' no --user (and no --api-key) is supplied. Never use in production.')
|
|
70
80
|
console.error('')
|
|
71
81
|
console.error('Examples:')
|
|
82
|
+
console.error(' OPEN_MERCATO_API_KEY=omk_xxxx.yyyy... mercato ai_assistant mcp:serve')
|
|
72
83
|
console.error(' mercato ai_assistant mcp:serve --api-key omk_xxxx.yyyy...')
|
|
73
|
-
console.error(' mercato ai_assistant mcp:serve --tenant 123e4567-e89b-12d3-a456-426614174000')
|
|
84
|
+
console.error(' mercato ai_assistant mcp:serve --tenant 123e4567-e89b-12d3-a456-426614174000 --user <user-id>')
|
|
85
|
+
console.error(' mercato ai_assistant mcp:serve --tenant 123e4567-e89b-12d3-a456-426614174000 --allow-unauthenticated-superadmin')
|
|
74
86
|
return
|
|
75
87
|
}
|
|
76
88
|
|
|
@@ -102,6 +114,7 @@ const mcpServe: ModuleCli = {
|
|
|
102
114
|
organizationId,
|
|
103
115
|
userId,
|
|
104
116
|
},
|
|
117
|
+
allowUnauthenticatedSuperadmin,
|
|
105
118
|
})
|
|
106
119
|
}
|
|
107
120
|
},
|
|
@@ -41,7 +41,11 @@ export default function McpConfigDialog({ open, onOpenChange, mcpUrl }: Props) {
|
|
|
41
41
|
setIsGenerating(true)
|
|
42
42
|
setError(null)
|
|
43
43
|
try {
|
|
44
|
-
|
|
44
|
+
// Use the dedicated MCP-key endpoint so the key inherits the current
|
|
45
|
+
// user's roles/ACL. A raw POST /api/api_keys/keys assigns no roles, which
|
|
46
|
+
// produced a key with zero features (the MCP server then exposes only
|
|
47
|
+
// `context_whoami`).
|
|
48
|
+
const res = await apiCall<ApiKeyResponse>('/api/ai_assistant/mcp-key', {
|
|
45
49
|
method: 'POST',
|
|
46
50
|
body: JSON.stringify({
|
|
47
51
|
name: `MCP Config - ${new Date().toLocaleDateString()}`,
|
|
@@ -38,6 +38,45 @@ describe('AI assistant nullable organization unique indexes', () => {
|
|
|
38
38
|
},
|
|
39
39
|
)
|
|
40
40
|
|
|
41
|
+
it('declares every snapshot expression index on the participants entity (no snapshot/entity drift)', () => {
|
|
42
|
+
const snapshot = JSON.parse(
|
|
43
|
+
readFileSync(
|
|
44
|
+
new URL('../../migrations/.snapshot-open-mercato.json', import.meta.url),
|
|
45
|
+
'utf8',
|
|
46
|
+
),
|
|
47
|
+
) as {
|
|
48
|
+
tables: Array<{ name: string; indexes?: Array<{ keyName: string; expression?: string }> }>
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const participants = snapshot.tables.find(
|
|
52
|
+
(table) => table.name === 'ai_chat_conversation_participants',
|
|
53
|
+
)
|
|
54
|
+
expect(participants).toBeDefined()
|
|
55
|
+
|
|
56
|
+
const entitySource = readFileSync(
|
|
57
|
+
new URL('../entities.ts', import.meta.url),
|
|
58
|
+
'utf8',
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
const expressionIndexes = (participants?.indexes ?? []).filter((index) => index.expression)
|
|
62
|
+
expect(expressionIndexes.length).toBeGreaterThan(0)
|
|
63
|
+
|
|
64
|
+
for (const index of expressionIndexes) {
|
|
65
|
+
expect(entitySource).toContain(index.keyName)
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('keeps the active-participants partial index declared on the entity', () => {
|
|
70
|
+
const entitySource = readFileSync(
|
|
71
|
+
new URL('../entities.ts', import.meta.url),
|
|
72
|
+
'utf8',
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
expect(entitySource).toContain(
|
|
76
|
+
'create index "ai_chat_conv_participants_active_conv_user_idx" on "ai_chat_conversation_participants" ("tenant_id", "organization_id", "conversation_id", "user_id") where "deleted_at" is null',
|
|
77
|
+
)
|
|
78
|
+
})
|
|
79
|
+
|
|
41
80
|
it('keeps the module snapshot aligned with partial unique indexes', () => {
|
|
42
81
|
const snapshot = JSON.parse(
|
|
43
82
|
readFileSync(
|
|
@@ -789,6 +789,11 @@ export class AiChatConversation {
|
|
|
789
789
|
expression:
|
|
790
790
|
'create unique index "ai_chat_conv_participants_tenant_conv_user_null_org_uq" on "ai_chat_conversation_participants" ("tenant_id", "conversation_id", "user_id") where "organization_id" is null',
|
|
791
791
|
})
|
|
792
|
+
@Index({
|
|
793
|
+
name: 'ai_chat_conv_participants_active_conv_user_idx',
|
|
794
|
+
expression:
|
|
795
|
+
'create index "ai_chat_conv_participants_active_conv_user_idx" on "ai_chat_conversation_participants" ("tenant_id", "organization_id", "conversation_id", "user_id") where "deleted_at" is null',
|
|
796
|
+
})
|
|
792
797
|
@Index({
|
|
793
798
|
name: 'ai_chat_conv_participants_tenant_org_user_conv_idx',
|
|
794
799
|
properties: ['tenantId', 'organizationId', 'userId', 'conversationId'],
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Coverage for the AI override pipeline. The pipeline is the only public
|
|
3
3
|
* way for downstream modules (or app-level code) to replace or disable
|
|
4
4
|
* an AI agent / AI tool registered by another module — see spec
|
|
5
|
-
* `.ai/specs/2026-04-30-ai-overrides-and-module-disable.md`.
|
|
5
|
+
* `.ai/specs/implemented/2026-04-30-ai-overrides-and-module-disable.md`.
|
|
6
6
|
*
|
|
7
7
|
* The pipeline has three tiers (highest precedence first):
|
|
8
8
|
* 1. programmatic — `applyAiAgentOverrides` / `applyAiToolOverrides`
|
|
@@ -349,6 +349,118 @@ describe('resolveAttachmentParts — tenant / org scope enforcement', () => {
|
|
|
349
349
|
expect(parts).toEqual([])
|
|
350
350
|
expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('not found'))
|
|
351
351
|
})
|
|
352
|
+
|
|
353
|
+
// Regression for #2663 — null-scoped rows used to bypass the tenant check via
|
|
354
|
+
// a truthiness short-circuit and leak into the caller's LLM context.
|
|
355
|
+
it('drops a null-tenant (global) record for a non super-admin caller', async () => {
|
|
356
|
+
findOneWithDecryptionMock.mockResolvedValueOnce(
|
|
357
|
+
makeRow({
|
|
358
|
+
id: 'null-tenant-1',
|
|
359
|
+
tenantId: null,
|
|
360
|
+
organizationId: null,
|
|
361
|
+
mimeType: 'image/png',
|
|
362
|
+
fileSize: 64,
|
|
363
|
+
content: 'secret OCR text',
|
|
364
|
+
}),
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
const parts = await resolveAttachmentParts({
|
|
368
|
+
attachmentIds: ['null-tenant-1'],
|
|
369
|
+
authContext: makeAuth({ tenantId: 'tenant-1', organizationId: 'org-1' }),
|
|
370
|
+
container: makeContainer(),
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
expect(parts).toEqual([])
|
|
374
|
+
expect(fsReadFileMock).not.toHaveBeenCalled()
|
|
375
|
+
expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('out of scope'))
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
// Regression for #2663 — `tenantId='X', organizationId=null` used to be
|
|
379
|
+
// readable by every org within tenant X.
|
|
380
|
+
it('drops a null-org record in the caller tenant for an org-scoped caller', async () => {
|
|
381
|
+
findOneWithDecryptionMock.mockResolvedValueOnce(
|
|
382
|
+
makeRow({
|
|
383
|
+
id: 'null-org-1',
|
|
384
|
+
tenantId: 'tenant-1',
|
|
385
|
+
organizationId: null,
|
|
386
|
+
mimeType: 'image/png',
|
|
387
|
+
fileSize: 64,
|
|
388
|
+
}),
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
const parts = await resolveAttachmentParts({
|
|
392
|
+
attachmentIds: ['null-org-1'],
|
|
393
|
+
authContext: makeAuth({ tenantId: 'tenant-1', organizationId: 'org-1' }),
|
|
394
|
+
container: makeContainer(),
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
expect(parts).toEqual([])
|
|
398
|
+
expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('out of scope'))
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
it('lets a tenant-wide caller (null org) read any org within its tenant', async () => {
|
|
402
|
+
findOneWithDecryptionMock.mockResolvedValueOnce(
|
|
403
|
+
makeRow({
|
|
404
|
+
id: 'same-tenant-other-org',
|
|
405
|
+
tenantId: 'tenant-1',
|
|
406
|
+
organizationId: 'org-OTHER',
|
|
407
|
+
mimeType: 'application/zip',
|
|
408
|
+
fileSize: 64,
|
|
409
|
+
content: null,
|
|
410
|
+
}),
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
const parts = await resolveAttachmentParts({
|
|
414
|
+
attachmentIds: ['same-tenant-other-org'],
|
|
415
|
+
authContext: makeAuth({ tenantId: 'tenant-1', organizationId: null }),
|
|
416
|
+
container: makeContainer(),
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
expect(parts).toHaveLength(1)
|
|
420
|
+
expect(parts[0].attachmentId).toBe('same-tenant-other-org')
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
it('scopes the ORM query by tenant + org for a non super-admin caller', async () => {
|
|
424
|
+
findOneWithDecryptionMock.mockResolvedValueOnce(makeRow({ id: 'att-scope-1', fileSize: 0 }))
|
|
425
|
+
|
|
426
|
+
await resolveAttachmentParts({
|
|
427
|
+
attachmentIds: ['att-scope-1'],
|
|
428
|
+
authContext: makeAuth({ tenantId: 'tenant-1', organizationId: 'org-1' }),
|
|
429
|
+
container: makeContainer(),
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
const whereArg = findOneWithDecryptionMock.mock.calls[0][2]
|
|
433
|
+
expect(whereArg).toEqual({ id: 'att-scope-1', tenantId: 'tenant-1', organizationId: 'org-1' })
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
it('omits org from the ORM query for a tenant-wide caller (null org)', async () => {
|
|
437
|
+
findOneWithDecryptionMock.mockResolvedValueOnce(makeRow({ id: 'att-scope-2', fileSize: 0 }))
|
|
438
|
+
|
|
439
|
+
await resolveAttachmentParts({
|
|
440
|
+
attachmentIds: ['att-scope-2'],
|
|
441
|
+
authContext: makeAuth({ tenantId: 'tenant-1', organizationId: null }),
|
|
442
|
+
container: makeContainer(),
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
const whereArg = findOneWithDecryptionMock.mock.calls[0][2]
|
|
446
|
+
expect(whereArg).toEqual({ id: 'att-scope-2', tenantId: 'tenant-1' })
|
|
447
|
+
expect(whereArg).not.toHaveProperty('organizationId')
|
|
448
|
+
})
|
|
449
|
+
|
|
450
|
+
it('does not scope the ORM query for super-admin callers', async () => {
|
|
451
|
+
findOneWithDecryptionMock.mockResolvedValueOnce(
|
|
452
|
+
makeRow({ id: 'att-scope-3', tenantId: 'tenant-OTHER', organizationId: 'org-OTHER', fileSize: 0 }),
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
await resolveAttachmentParts({
|
|
456
|
+
attachmentIds: ['att-scope-3'],
|
|
457
|
+
authContext: makeAuth({ isSuperAdmin: true }),
|
|
458
|
+
container: makeContainer(),
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
const whereArg = findOneWithDecryptionMock.mock.calls[0][2]
|
|
462
|
+
expect(whereArg).toEqual({ id: 'att-scope-3' })
|
|
463
|
+
})
|
|
352
464
|
})
|
|
353
465
|
|
|
354
466
|
describe('resolveAttachmentParts — unavailable service graceful skip', () => {
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { createMcpClient } from '../client-factory'
|
|
2
|
+
|
|
3
|
+
const mockConnect = jest.fn(async () => ({ listTools: jest.fn(), callTool: jest.fn(), close: jest.fn() }))
|
|
4
|
+
|
|
5
|
+
jest.mock('../mcp-client', () => ({
|
|
6
|
+
McpClient: {
|
|
7
|
+
connect: (...args: unknown[]) => mockConnect(...args),
|
|
8
|
+
},
|
|
9
|
+
}))
|
|
10
|
+
|
|
11
|
+
describe('createMcpClient stdio mode (issue #2669)', () => {
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
jest.clearAllMocks()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('never places the API key on the default argv', async () => {
|
|
17
|
+
await createMcpClient({ mode: 'stdio', apiKeySecret: 'omk_secret.value' })
|
|
18
|
+
|
|
19
|
+
expect(mockConnect).toHaveBeenCalledTimes(1)
|
|
20
|
+
const options = mockConnect.mock.calls[0][0] as { apiKeySecret: string; args?: string[] }
|
|
21
|
+
|
|
22
|
+
// The secret is forwarded to McpClient.connect, which delivers it via env.
|
|
23
|
+
expect(options.apiKeySecret).toBe('omk_secret.value')
|
|
24
|
+
|
|
25
|
+
// The factory must not build default args that embed the secret. Leaving
|
|
26
|
+
// args undefined lets connectStdio use its secret-free default.
|
|
27
|
+
expect(options.args).toBeUndefined()
|
|
28
|
+
expect(JSON.stringify(options.args ?? [])).not.toContain('--api-key')
|
|
29
|
+
expect(JSON.stringify(options.args ?? [])).not.toContain('omk_secret.value')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('honors explicitly provided stdio args without injecting the secret', async () => {
|
|
33
|
+
await createMcpClient({
|
|
34
|
+
mode: 'stdio',
|
|
35
|
+
apiKeySecret: 'omk_secret.value',
|
|
36
|
+
stdioArgs: ['node', 'custom-server.js'],
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
const options = mockConnect.mock.calls[0][0] as { args?: string[] }
|
|
40
|
+
expect(options.args).toEqual(['node', 'custom-server.js'])
|
|
41
|
+
})
|
|
42
|
+
})
|