@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
|
@@ -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
|
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* a curated model list for the AI Assistant UI dropdown.
|
|
7
7
|
*
|
|
8
8
|
* @see packages/shared/src/lib/ai/llm-provider.ts
|
|
9
|
-
* @see .ai/specs/2026-04-14-llm-provider-ports-and-adapters.md
|
|
9
|
+
* @see .ai/specs/implemented/2026-04-14-llm-provider-ports-and-adapters.md
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { createAnthropic } from '@ai-sdk/anthropic'
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Wraps `createGoogleGenerativeAI({ apiKey })` from `@ai-sdk/google`.
|
|
6
6
|
*
|
|
7
7
|
* @see packages/shared/src/lib/ai/llm-provider.ts
|
|
8
|
-
* @see .ai/specs/2026-04-14-llm-provider-ports-and-adapters.md
|
|
8
|
+
* @see .ai/specs/implemented/2026-04-14-llm-provider-ports-and-adapters.md
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { createGoogleGenerativeAI } from '@ai-sdk/google'
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*
|
|
15
15
|
* @see packages/shared/src/lib/ai/llm-provider.ts
|
|
16
16
|
* @see ./openai-compatible-presets.ts
|
|
17
|
-
* @see .ai/specs/2026-04-14-llm-provider-ports-and-adapters.md
|
|
17
|
+
* @see .ai/specs/implemented/2026-04-14-llm-provider-ports-and-adapters.md
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { createOpenAI } from '@ai-sdk/openai'
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* `console.warn` but leaves the rest of the registry working.
|
|
12
12
|
*
|
|
13
13
|
* @see packages/shared/src/lib/ai/llm-provider-registry.ts
|
|
14
|
-
* @see .ai/specs/2026-04-14-llm-provider-ports-and-adapters.md
|
|
14
|
+
* @see .ai/specs/implemented/2026-04-14-llm-provider-ports-and-adapters.md
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { llmProviderRegistry } from '@open-mercato/shared/lib/ai/llm-provider-registry'
|
|
@@ -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
|
*/
|
|
@@ -17,7 +17,14 @@ import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacS
|
|
|
17
17
|
* Create and configure an MCP server instance.
|
|
18
18
|
*/
|
|
19
19
|
export async function createMcpServer(options: McpServerOptions): Promise<Server> {
|
|
20
|
-
const { config, container, context,
|
|
20
|
+
const { config, container, context, allowUnauthenticatedSuperadmin } = options
|
|
21
|
+
|
|
22
|
+
// Treat empty / whitespace-only secrets as missing so a blank api key cannot
|
|
23
|
+
// fall through into an unauthenticated branch.
|
|
24
|
+
const apiKeySecret =
|
|
25
|
+
typeof options.apiKeySecret === 'string' && options.apiKeySecret.trim().length > 0
|
|
26
|
+
? options.apiKeySecret
|
|
27
|
+
: undefined
|
|
21
28
|
|
|
22
29
|
let tenantId: string | null = null
|
|
23
30
|
let organizationId: string | null = null
|
|
@@ -37,41 +44,48 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
|
|
|
37
44
|
userFeatures = authResult.features
|
|
38
45
|
isSuperAdmin = authResult.isSuperAdmin
|
|
39
46
|
console.error(`[MCP Server] Authenticated via API key: ${authResult.keyName}`)
|
|
40
|
-
} else if (context) {
|
|
41
|
-
// Manual context
|
|
47
|
+
} else if (context && context.userId) {
|
|
48
|
+
// Manual context with a real user — load that user's ACL.
|
|
42
49
|
tenantId = context.tenantId
|
|
43
50
|
organizationId = context.organizationId
|
|
44
51
|
userId = context.userId
|
|
45
52
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}>
|
|
56
|
-
}
|
|
57
|
-
const acl = await rbacService.loadAcl(userId, {
|
|
58
|
-
tenantId,
|
|
59
|
-
organizationId,
|
|
60
|
-
})
|
|
61
|
-
userFeatures = acl.features
|
|
62
|
-
isSuperAdmin = acl.isSuperAdmin
|
|
63
|
-
} catch (error) {
|
|
64
|
-
console.error('[MCP Server] Failed to load user ACL:', error)
|
|
53
|
+
try {
|
|
54
|
+
const rbacService = container.resolve('rbacService') as {
|
|
55
|
+
loadAcl: (
|
|
56
|
+
userId: string,
|
|
57
|
+
scope: { tenantId: string | null; organizationId: string | null }
|
|
58
|
+
) => Promise<{
|
|
59
|
+
isSuperAdmin: boolean
|
|
60
|
+
features: string[]
|
|
61
|
+
}>
|
|
65
62
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
63
|
+
const acl = await rbacService.loadAcl(userId, {
|
|
64
|
+
tenantId,
|
|
65
|
+
organizationId,
|
|
66
|
+
})
|
|
67
|
+
userFeatures = acl.features
|
|
68
|
+
isSuperAdmin = acl.isSuperAdmin
|
|
69
|
+
} catch (error) {
|
|
70
|
+
console.error('[MCP Server] Failed to load user ACL:', error)
|
|
71
|
+
}
|
|
72
|
+
} else if (allowUnauthenticatedSuperadmin) {
|
|
73
|
+
// Explicit, loud dev/testing opt-in. Without a user there is no ACL to load,
|
|
74
|
+
// so the server runs as superadmin with no tenant scoping beyond whatever the
|
|
75
|
+
// caller pinned via `context`. NEVER enable this in production.
|
|
76
|
+
if (context) {
|
|
77
|
+
tenantId = context.tenantId
|
|
78
|
+
organizationId = context.organizationId
|
|
70
79
|
}
|
|
71
|
-
} else {
|
|
72
|
-
// No context and no API key - superadmin for dev/testing
|
|
73
80
|
isSuperAdmin = true
|
|
74
|
-
console.error(
|
|
81
|
+
console.error(
|
|
82
|
+
'[MCP Server] WARNING: allowUnauthenticatedSuperadmin is enabled — running with UNAUTHENTICATED SUPERADMIN access and no per-user ACL. Do not use this outside local development/testing.'
|
|
83
|
+
)
|
|
84
|
+
} else {
|
|
85
|
+
// Fail closed: refuse to start rather than silently escalating to superadmin.
|
|
86
|
+
throw new Error(
|
|
87
|
+
'[internal] MCP server refused to start: no authentication provided. Supply a valid apiKeySecret, a context with a non-empty userId, or explicitly set allowUnauthenticatedSuperadmin: true for local development.'
|
|
88
|
+
)
|
|
75
89
|
}
|
|
76
90
|
|
|
77
91
|
const toolContext: McpToolContext = {
|
|
@@ -200,14 +214,14 @@ export async function runMcpServer(options: McpServerOptions): Promise<void> {
|
|
|
200
214
|
|
|
201
215
|
console.error(`[MCP Server] Starting ${options.config.name} v${options.config.version}`)
|
|
202
216
|
|
|
203
|
-
if (options.apiKeySecret) {
|
|
217
|
+
if (options.apiKeySecret && options.apiKeySecret.trim().length > 0) {
|
|
204
218
|
console.error(`[MCP Server] Authentication: API key`)
|
|
205
|
-
} else if (options.context) {
|
|
219
|
+
} else if (options.context && options.context.userId) {
|
|
206
220
|
console.error(`[MCP Server] Tenant: ${options.context.tenantId ?? '(none)'}`)
|
|
207
221
|
console.error(`[MCP Server] Organization: ${options.context.organizationId ?? '(none)'}`)
|
|
208
|
-
console.error(`[MCP Server] User: ${options.context.userId
|
|
222
|
+
console.error(`[MCP Server] User: ${options.context.userId}`)
|
|
209
223
|
} else {
|
|
210
|
-
console.error(`[MCP Server] Authentication: none (superadmin
|
|
224
|
+
console.error(`[MCP Server] Authentication: none (unauthenticated superadmin opt-in)`)
|
|
211
225
|
}
|
|
212
226
|
|
|
213
227
|
console.error(`[MCP Server] Tools registered: ${toolCount}`)
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* file.
|
|
13
13
|
*
|
|
14
14
|
* @see ./llm-adapters/openai.ts
|
|
15
|
-
* @see .ai/specs/2026-04-14-llm-provider-ports-and-adapters.md
|
|
15
|
+
* @see .ai/specs/implemented/2026-04-14-llm-provider-ports-and-adapters.md
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import type { OpenAICompatiblePreset } from './llm-adapters/openai'
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Visible AI chat agent task plan — server-side SSE injector.
|
|
3
3
|
*
|
|
4
|
-
* Spec: `.ai/specs/2026-05-13-ai-chat-visible-task-plan.md`.
|
|
4
|
+
* Spec: `.ai/specs/implemented/2026-05-13-ai-chat-visible-task-plan.md`.
|
|
5
5
|
*
|
|
6
6
|
* Wraps a streaming `Response` produced by `streamText().toUIMessageStreamResponse()`
|
|
7
7
|
* (or the equivalent `ToolLoopAgent.stream(...).toUIMessageStreamResponse()`)
|
|
@@ -217,6 +217,13 @@ export interface McpServerOptions {
|
|
|
217
217
|
}
|
|
218
218
|
/** API key secret for authentication (alternative to manual context) */
|
|
219
219
|
apiKeySecret?: string
|
|
220
|
+
/**
|
|
221
|
+
* Explicit, loud opt-in for running without authentication as a superadmin
|
|
222
|
+
* (development/testing only). When false/unset and no `apiKeySecret` or
|
|
223
|
+
* `context.userId` is supplied, the server fails closed and refuses to start
|
|
224
|
+
* instead of silently escalating to superadmin. NEVER enable in production.
|
|
225
|
+
*/
|
|
226
|
+
allowUnauthenticatedSuperadmin?: boolean
|
|
220
227
|
}
|
|
221
228
|
|
|
222
229
|
/**
|
|
@@ -1,7 +1,16 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
1
2
|
import type { ModuleSetupConfig } from '@open-mercato/shared/modules/setup'
|
|
2
3
|
|
|
3
|
-
const
|
|
4
|
-
const
|
|
4
|
+
const PENDING_ACTION_CLEANUP_SCHEDULE_KEY = 'ai_assistant:pending-action-cleanup'
|
|
5
|
+
const TOKEN_USAGE_PRUNE_SCHEDULE_KEY = 'ai_assistant:token-usage-prune'
|
|
6
|
+
|
|
7
|
+
function stableScheduleUuid(stableKey: string): string {
|
|
8
|
+
const hex = createHash('sha256').update(stableKey).digest('hex')
|
|
9
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const PENDING_ACTION_CLEANUP_SCHEDULE_ID = stableScheduleUuid(PENDING_ACTION_CLEANUP_SCHEDULE_KEY)
|
|
13
|
+
const TOKEN_USAGE_PRUNE_SCHEDULE_ID = stableScheduleUuid(TOKEN_USAGE_PRUNE_SCHEDULE_KEY)
|
|
5
14
|
|
|
6
15
|
/**
|
|
7
16
|
* System-scoped recurring schedule: every 5 minutes, enqueue a job to the
|