auvrynt 1.0.1 → 1.0.2
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/README.md +9 -4
- package/dist/cli.js +462 -51
- package/dist/config.js +34 -2
- package/dist/connection-registry.js +73 -0
- package/dist/connection-status.js +48 -0
- package/dist/integration-discovery.js +98 -0
- package/dist/oauth-provider.js +147 -36
- package/dist/server.js +23 -1
- package/docs/DEVELOPMENT_TOOLS.md +10 -5
- package/docs/configuration.md +4 -4
- package/docs/gotchas.md +5 -5
- package/docs/setup.md +3 -3
- package/package.json +3 -3
package/dist/config.js
CHANGED
|
@@ -4,6 +4,26 @@ import { expandHomePath } from "./roots.js";
|
|
|
4
4
|
import { loadAuvryntFiles } from "./user-config.js";
|
|
5
5
|
const DEFAULT_OAUTH_ACCESS_TOKEN_TTL_SECONDS = 60 * 60;
|
|
6
6
|
const DEFAULT_OAUTH_REFRESH_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60;
|
|
7
|
+
export const SUPPORTED_SCOPES = [
|
|
8
|
+
"auvrynt:read",
|
|
9
|
+
"auvrynt:write",
|
|
10
|
+
"auvrynt:process",
|
|
11
|
+
"auvrynt:web",
|
|
12
|
+
"auvrynt:software",
|
|
13
|
+
"auvrynt:godot",
|
|
14
|
+
"auvrynt:blender",
|
|
15
|
+
"auvrynt:serena",
|
|
16
|
+
];
|
|
17
|
+
export const SCOPE_DESCRIPTIONS = {
|
|
18
|
+
"auvrynt:read": "Inspect files, search, and perform read-only project analysis",
|
|
19
|
+
"auvrynt:write": "Edit and create files",
|
|
20
|
+
"auvrynt:process": "Start and stop approved workspace processes",
|
|
21
|
+
"auvrynt:web": "Use browser and web-development tools",
|
|
22
|
+
"auvrynt:software": "Use software and .NET tools",
|
|
23
|
+
"auvrynt:godot": "Use Godot project tools",
|
|
24
|
+
"auvrynt:blender": "Use Blender 3D tools",
|
|
25
|
+
"auvrynt:serena": "Use local Serena semantic code tools",
|
|
26
|
+
};
|
|
7
27
|
function parsePort(value) {
|
|
8
28
|
if (value === undefined || value === "")
|
|
9
29
|
return 49321;
|
|
@@ -133,9 +153,11 @@ function parseOAuthConfig(env, ownerToken) {
|
|
|
133
153
|
ownerToken: parseRequiredSecret(env.AUVRYNT_OAUTH_OWNER_TOKEN ?? ownerToken, "AUVRYNT_OAUTH_OWNER_TOKEN"),
|
|
134
154
|
accessTokenTtlSeconds: parsePositiveInteger(env.AUVRYNT_OAUTH_ACCESS_TOKEN_TTL_SECONDS, DEFAULT_OAUTH_ACCESS_TOKEN_TTL_SECONDS, "AUVRYNT_OAUTH_ACCESS_TOKEN_TTL_SECONDS"),
|
|
135
155
|
refreshTokenTtlSeconds: parsePositiveInteger(env.AUVRYNT_OAUTH_REFRESH_TOKEN_TTL_SECONDS, DEFAULT_OAUTH_REFRESH_TOKEN_TTL_SECONDS, "AUVRYNT_OAUTH_REFRESH_TOKEN_TTL_SECONDS"),
|
|
136
|
-
scopes: parseStringList(env.AUVRYNT_OAUTH_SCOPES, [
|
|
156
|
+
scopes: parseStringList(env.AUVRYNT_OAUTH_SCOPES, [...SUPPORTED_SCOPES]),
|
|
137
157
|
allowedRedirectHosts: parseStringList(env.AUVRYNT_OAUTH_ALLOWED_REDIRECT_HOSTS, [
|
|
138
158
|
"chatgpt.com",
|
|
159
|
+
"claude.ai",
|
|
160
|
+
"claude.com",
|
|
139
161
|
"localhost",
|
|
140
162
|
"127.0.0.1",
|
|
141
163
|
]),
|
|
@@ -154,7 +176,7 @@ function parseSerenaConfig(env, filesConfig) {
|
|
|
154
176
|
const filesSerena = filesConfig.serena ?? {};
|
|
155
177
|
return {
|
|
156
178
|
enabled: parseBoolean(env.AUVRYNT_SERENA_ENABLED ?? filesSerena.enabled),
|
|
157
|
-
executable: env.AUVRYNT_SERENA_EXECUTABLE ?? filesSerena.executable ?? "serena",
|
|
179
|
+
executable: env.AUVRYNT_SERENA_EXECUTABLE ?? env.AUVRYNT_SERENA_PATH ?? filesConfig.executables?.serena ?? filesSerena.executable ?? "serena",
|
|
158
180
|
backend: (env.AUVRYNT_SERENA_BACKEND ?? filesSerena.backend ?? "LSP"),
|
|
159
181
|
context: env.AUVRYNT_SERENA_CONTEXT ?? filesSerena.context ?? "desktop-app",
|
|
160
182
|
startupTimeoutMs: parsePositiveInteger(env.AUVRYNT_SERENA_STARTUP_TIMEOUT ?? filesSerena.startupTimeoutMs, 30_000, "AUVRYNT_SERENA_STARTUP_TIMEOUT"),
|
|
@@ -163,6 +185,15 @@ function parseSerenaConfig(env, filesConfig) {
|
|
|
163
185
|
maxInstances: parsePositiveInteger(env.AUVRYNT_SERENA_MAX_INSTANCES ?? filesSerena.maxInstances, 3, "AUVRYNT_SERENA_MAX_INSTANCES"),
|
|
164
186
|
};
|
|
165
187
|
}
|
|
188
|
+
function parseExecutablesConfig(env, filesConfig) {
|
|
189
|
+
const configExecs = filesConfig.executables ?? {};
|
|
190
|
+
return {
|
|
191
|
+
serena: env.AUVRYNT_SERENA_PATH ?? configExecs.serena,
|
|
192
|
+
godot: env.AUVRYNT_GODOT_PATH ?? configExecs.godot,
|
|
193
|
+
godotCsharp: env.AUVRYNT_GODOT_CSHARP_PATH ?? configExecs.godotCsharp,
|
|
194
|
+
blender: env.AUVRYNT_BLENDER_PATH ?? configExecs.blender,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
166
197
|
export function loadConfig(env = process.env) {
|
|
167
198
|
const files = loadAuvryntFiles(env);
|
|
168
199
|
const host = env.HOST ?? files.config.host ?? "127.0.0.1";
|
|
@@ -193,6 +224,7 @@ export function loadConfig(env = process.env) {
|
|
|
193
224
|
agentDir: resolve(expandHomePath(env.AUVRYNT_AGENT_DIR ?? files.config.agentDir ?? defaultAgentDir())),
|
|
194
225
|
logging: parseLoggingConfig(env),
|
|
195
226
|
serena: parseSerenaConfig(env, files.config),
|
|
227
|
+
executables: parseExecutablesConfig(env, files.config),
|
|
196
228
|
};
|
|
197
229
|
}
|
|
198
230
|
function parsePublicBaseUrl(value) {
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
const REGISTRY_FILE = "connections.json";
|
|
4
|
+
export function connectionRegistryPath(stateDir) {
|
|
5
|
+
return join(stateDir, REGISTRY_FILE);
|
|
6
|
+
}
|
|
7
|
+
export function identifyProvider(clientName, userAgent) {
|
|
8
|
+
const value = `${clientName ?? ""} ${userAgent ?? ""}`.trim();
|
|
9
|
+
const normalized = value.toLowerCase();
|
|
10
|
+
const knownProviders = [
|
|
11
|
+
["chatgpt", "ChatGPT"],
|
|
12
|
+
["openai", "OpenAI"],
|
|
13
|
+
["claude", "Claude"],
|
|
14
|
+
["anthropic", "Claude / Anthropic"],
|
|
15
|
+
["kimi", "Kimi"],
|
|
16
|
+
["cursor", "Cursor"],
|
|
17
|
+
["windsurf", "Windsurf"],
|
|
18
|
+
["gemini", "Gemini"],
|
|
19
|
+
["vscode", "VS Code"],
|
|
20
|
+
["visual studio code", "VS Code"],
|
|
21
|
+
["codex", "Codex"],
|
|
22
|
+
];
|
|
23
|
+
for (const [marker, provider] of knownProviders) {
|
|
24
|
+
if (normalized.includes(marker))
|
|
25
|
+
return provider;
|
|
26
|
+
}
|
|
27
|
+
if (clientName?.trim())
|
|
28
|
+
return clientName.trim().slice(0, 80);
|
|
29
|
+
if (userAgent?.trim())
|
|
30
|
+
return userAgent.trim().split(/[\s/]/, 1)[0].slice(0, 80) || "Unknown MCP client";
|
|
31
|
+
return "Unknown MCP client";
|
|
32
|
+
}
|
|
33
|
+
function sanitizeUserAgent(userAgent) {
|
|
34
|
+
if (!userAgent)
|
|
35
|
+
return undefined;
|
|
36
|
+
return userAgent.replace(/[\r\n]/g, " ").slice(0, 160);
|
|
37
|
+
}
|
|
38
|
+
export function readConnectedClients(stateDir) {
|
|
39
|
+
const path = connectionRegistryPath(stateDir);
|
|
40
|
+
if (!existsSync(path))
|
|
41
|
+
return [];
|
|
42
|
+
try {
|
|
43
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
44
|
+
if (!Array.isArray(parsed))
|
|
45
|
+
return [];
|
|
46
|
+
return parsed.filter((entry) => Boolean(entry && typeof entry === "object"));
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export function recordConnectedClient(stateDir, input) {
|
|
53
|
+
mkdirSync(stateDir, { recursive: true });
|
|
54
|
+
const provider = identifyProvider(input.clientName, input.userAgent);
|
|
55
|
+
const clients = readConnectedClients(stateDir);
|
|
56
|
+
const existing = clients.find((client) => client.provider === provider);
|
|
57
|
+
const now = new Date().toISOString();
|
|
58
|
+
if (existing) {
|
|
59
|
+
existing.lastSeen = now;
|
|
60
|
+
existing.requestCount += 1;
|
|
61
|
+
existing.userAgent = sanitizeUserAgent(input.userAgent) ?? existing.userAgent;
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
clients.push({
|
|
65
|
+
provider,
|
|
66
|
+
userAgent: sanitizeUserAgent(input.userAgent),
|
|
67
|
+
lastSeen: now,
|
|
68
|
+
requestCount: 1,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
clients.sort((left, right) => right.lastSeen.localeCompare(left.lastSeen));
|
|
72
|
+
writeFileSync(connectionRegistryPath(stateDir), JSON.stringify(clients.slice(0, 32), null, 2) + "\n", { mode: 0o600 });
|
|
73
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
2
|
import { getBlenderClient } from "./blender-client.js";
|
|
3
3
|
import { godotEditorStatus } from "./godot-editor-bridge.js";
|
|
4
|
+
import { discoverLocalIntegrations, processDetected } from "./integration-discovery.js";
|
|
4
5
|
const require = createRequire(import.meta.url);
|
|
5
6
|
function checkedAt() {
|
|
6
7
|
return new Date().toISOString();
|
|
@@ -29,6 +30,7 @@ function browserStatus() {
|
|
|
29
30
|
export async function getConnectionStatus(registry, processManager, workspaceId) {
|
|
30
31
|
const workspace = registry.getWorkspace(workspaceId);
|
|
31
32
|
const timestamp = checkedAt();
|
|
33
|
+
const local = await discoverLocalIntegrations();
|
|
32
34
|
const blender = {
|
|
33
35
|
state: "disconnected",
|
|
34
36
|
provider: "Auvrynt Blender bridge",
|
|
@@ -45,6 +47,17 @@ export async function getConnectionStatus(registry, processManager, workspaceId)
|
|
|
45
47
|
catch (error) {
|
|
46
48
|
blender.detail = error instanceof Error ? error.message : String(error);
|
|
47
49
|
}
|
|
50
|
+
const blenderLabMcp = {
|
|
51
|
+
state: local.ports.blender_lab_mcp ? "connected" : processDetected(local, "blender") ? "available" : "disconnected",
|
|
52
|
+
provider: "Blender Lab MCP",
|
|
53
|
+
endpoint: `127.0.0.1:${process.env.AUVRYNT_BLENDER_MCP_PORT ?? "9876"}`,
|
|
54
|
+
detail: local.ports.blender_lab_mcp
|
|
55
|
+
? "Blender MCP is listening on the configured Blender Lab endpoint."
|
|
56
|
+
: processDetected(local, "blender")
|
|
57
|
+
? "Blender is running, but its MCP endpoint is not reachable. Check Blender MCP Auto Start and port 9876."
|
|
58
|
+
: "Blender is not detected. Start Blender with the MCP extension enabled for automatic connection.",
|
|
59
|
+
checkedAt: timestamp,
|
|
60
|
+
};
|
|
48
61
|
let godot;
|
|
49
62
|
try {
|
|
50
63
|
const response = await godotEditorStatus({ workspaceId });
|
|
@@ -66,6 +79,37 @@ export async function getConnectionStatus(registry, processManager, workspaceId)
|
|
|
66
79
|
checkedAt: timestamp,
|
|
67
80
|
};
|
|
68
81
|
}
|
|
82
|
+
const godotMcp = {
|
|
83
|
+
state: local.ports.auvrynt_godot_bridge ? "connected" : processDetected(local, "godot") ? "available" : "disconnected",
|
|
84
|
+
provider: "Godot / Codex MCP",
|
|
85
|
+
endpoint: "local process or 127.0.0.1:49322",
|
|
86
|
+
detail: local.ports.auvrynt_godot_bridge
|
|
87
|
+
? "Auvrynt Godot bridge is reachable."
|
|
88
|
+
: processDetected(local, "godot")
|
|
89
|
+
? "Godot is running, but no reachable Auvrynt bridge was found. Enable the bridge or Codex Godot MCP."
|
|
90
|
+
: "Godot is not detected. Start Godot with the Auvrynt bridge or Codex Godot MCP enabled.",
|
|
91
|
+
checkedAt: timestamp,
|
|
92
|
+
};
|
|
93
|
+
const cloudflare = {
|
|
94
|
+
state: processDetected(local, "cloudflare_tunnel") ? "connected" : local.executables.cloudflared ? "available" : "unavailable",
|
|
95
|
+
provider: "Cloudflare Tunnel",
|
|
96
|
+
detail: processDetected(local, "cloudflare_tunnel")
|
|
97
|
+
? "cloudflared is running."
|
|
98
|
+
: local.executables.cloudflared
|
|
99
|
+
? `cloudflared is installed at ${local.executables.cloudflared}, but no tunnel process is running.`
|
|
100
|
+
: "cloudflared is not installed. Install it to expose /mcp through Cloudflare Tunnel.",
|
|
101
|
+
checkedAt: timestamp,
|
|
102
|
+
};
|
|
103
|
+
const serena = {
|
|
104
|
+
state: processDetected(local, "serena") ? "connected" : local.executables.serena ? "available" : "unavailable",
|
|
105
|
+
provider: "Serena",
|
|
106
|
+
detail: processDetected(local, "serena")
|
|
107
|
+
? "Serena process detected."
|
|
108
|
+
: local.executables.serena
|
|
109
|
+
? `Serena is installed at ${local.executables.serena} and available on demand.`
|
|
110
|
+
: "Serena is not installed or not available on PATH.",
|
|
111
|
+
checkedAt: timestamp,
|
|
112
|
+
};
|
|
69
113
|
return {
|
|
70
114
|
checkedAt: timestamp,
|
|
71
115
|
workspace: { id: workspace.id, root: workspace.root, mode: workspace.mode },
|
|
@@ -77,7 +121,11 @@ export async function getConnectionStatus(registry, processManager, workspaceId)
|
|
|
77
121
|
checkedAt: timestamp,
|
|
78
122
|
},
|
|
79
123
|
blender,
|
|
124
|
+
blender_lab_mcp: blenderLabMcp,
|
|
80
125
|
godot,
|
|
126
|
+
godot_mcp: godotMcp,
|
|
127
|
+
cloudflare_tunnel: cloudflare,
|
|
128
|
+
serena,
|
|
81
129
|
browser: browserStatus(),
|
|
82
130
|
chrome_mcp: {
|
|
83
131
|
state: "not_configured",
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { createConnection } from "node:net";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
import { loadConfig } from "./config.js";
|
|
5
|
+
const execFileAsync = promisify(execFile);
|
|
6
|
+
const PROCESS_MARKERS = {
|
|
7
|
+
blender: ["blender.exe", "blender"],
|
|
8
|
+
godot: ["godot.exe", "godot4.exe", "godot"],
|
|
9
|
+
cloudflare_tunnel: ["cloudflared.exe", "cloudflared"],
|
|
10
|
+
serena: ["serena.exe", "serena"],
|
|
11
|
+
};
|
|
12
|
+
function probePort(host, port, timeoutMs = 350) {
|
|
13
|
+
return new Promise((resolve) => {
|
|
14
|
+
const socket = createConnection({ host, port });
|
|
15
|
+
const finish = (available) => {
|
|
16
|
+
socket.destroy();
|
|
17
|
+
resolve(available);
|
|
18
|
+
};
|
|
19
|
+
const timeout = setTimeout(() => finish(false), timeoutMs);
|
|
20
|
+
socket.once("connect", () => {
|
|
21
|
+
clearTimeout(timeout);
|
|
22
|
+
finish(true);
|
|
23
|
+
});
|
|
24
|
+
socket.once("error", () => {
|
|
25
|
+
clearTimeout(timeout);
|
|
26
|
+
finish(false);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
async function runningProcessNames() {
|
|
31
|
+
if (process.platform !== "win32")
|
|
32
|
+
return [];
|
|
33
|
+
try {
|
|
34
|
+
const { stdout } = await execFileAsync("tasklist.exe", ["/FO", "CSV", "/NH"], { timeout: 5_000 });
|
|
35
|
+
return stdout
|
|
36
|
+
.split(/\r?\n/)
|
|
37
|
+
.map((line) => line.match(/^"([^"]+)"/)?.[1]?.toLowerCase())
|
|
38
|
+
.filter((name) => Boolean(name));
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
async function findExecutable(command) {
|
|
45
|
+
if (process.platform !== "win32")
|
|
46
|
+
return undefined;
|
|
47
|
+
try {
|
|
48
|
+
const { stdout } = await execFileAsync("where.exe", [command], { timeout: 5_000 });
|
|
49
|
+
return stdout.split(/\r?\n/).map((line) => line.trim()).find(Boolean);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function hasProcess(processes, markers) {
|
|
56
|
+
return processes.some((processName) => markers.some((marker) => processName === marker || processName.includes(marker)));
|
|
57
|
+
}
|
|
58
|
+
export async function discoverLocalIntegrations() {
|
|
59
|
+
let configExecs = {};
|
|
60
|
+
try {
|
|
61
|
+
const config = loadConfig();
|
|
62
|
+
configExecs = config.executables;
|
|
63
|
+
}
|
|
64
|
+
catch { }
|
|
65
|
+
const blenderMcpPort = Number(process.env.AUVRYNT_BLENDER_MCP_PORT ?? 9876);
|
|
66
|
+
const [processes, cloudflaredSys, serenaSys, godotSys, blenderSys, blenderLabMcp, auvryntBlenderBridge, auvryntGodotBridge] = await Promise.all([
|
|
67
|
+
runningProcessNames(),
|
|
68
|
+
findExecutable("cloudflared"),
|
|
69
|
+
findExecutable("serena"),
|
|
70
|
+
findExecutable("godot"),
|
|
71
|
+
findExecutable("blender"),
|
|
72
|
+
probePort("127.0.0.1", blenderMcpPort),
|
|
73
|
+
probePort("127.0.0.1", 49323),
|
|
74
|
+
probePort("127.0.0.1", 49322),
|
|
75
|
+
]);
|
|
76
|
+
const serena = configExecs.serena || serenaSys;
|
|
77
|
+
const godot = configExecs.godot || godotSys;
|
|
78
|
+
const godotCsharp = configExecs.godotCsharp;
|
|
79
|
+
const blender = configExecs.blender || blenderSys;
|
|
80
|
+
return {
|
|
81
|
+
processes,
|
|
82
|
+
executables: {
|
|
83
|
+
cloudflared: cloudflaredSys,
|
|
84
|
+
serena,
|
|
85
|
+
godot,
|
|
86
|
+
godotCsharp,
|
|
87
|
+
blender,
|
|
88
|
+
},
|
|
89
|
+
ports: {
|
|
90
|
+
blender_lab_mcp: blenderLabMcp,
|
|
91
|
+
auvrynt_blender_bridge: auvryntBlenderBridge,
|
|
92
|
+
auvrynt_godot_bridge: auvryntGodotBridge,
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
export function processDetected(discovery, integration) {
|
|
97
|
+
return hasProcess(discovery.processes, PROCESS_MARKERS[integration]);
|
|
98
|
+
}
|
package/dist/oauth-provider.js
CHANGED
|
@@ -21,10 +21,25 @@ function htmlEscape(value) {
|
|
|
21
21
|
.replaceAll("'", "'");
|
|
22
22
|
}
|
|
23
23
|
function formHtml(params) {
|
|
24
|
-
const
|
|
24
|
+
const scopeDescriptions = {
|
|
25
|
+
"auvrynt:read": "Inspect files, search, and perform read-only project analysis",
|
|
26
|
+
"auvrynt:write": "Edit and create files",
|
|
27
|
+
"auvrynt:process": "Start and stop approved workspace processes",
|
|
28
|
+
"auvrynt:web": "Use browser and web-development tools",
|
|
29
|
+
"auvrynt:software": "Use software and .NET tools",
|
|
30
|
+
"auvrynt:godot": "Use Godot project tools",
|
|
31
|
+
"auvrynt:blender": "Use Blender 3D tools",
|
|
32
|
+
"auvrynt:serena": "Use local Serena semantic code tools",
|
|
33
|
+
};
|
|
34
|
+
const scopeItems = params.scopes.length > 0
|
|
35
|
+
? params.scopes.map((s) => {
|
|
36
|
+
const desc = scopeDescriptions[s];
|
|
37
|
+
return `<li><strong>${htmlEscape(s)}</strong>${desc ? ` <span>${htmlEscape(desc)}</span>` : ""}</li>`;
|
|
38
|
+
}).join("\n ")
|
|
39
|
+
: "<li><strong>auvrynt</strong><span>General access</span></li>";
|
|
25
40
|
const resourceText = params.resource?.href ?? "Auvrynt Webkit Arsenal endpoint";
|
|
26
41
|
const error = params.error
|
|
27
|
-
? `<
|
|
42
|
+
? `<output role="alert" class="error">${htmlEscape(params.error)}</output>`
|
|
28
43
|
: "";
|
|
29
44
|
const hiddenFields = Object.entries(params.fields)
|
|
30
45
|
.filter((entry) => entry[1] !== undefined)
|
|
@@ -35,39 +50,127 @@ function formHtml(params) {
|
|
|
35
50
|
<head>
|
|
36
51
|
<meta charset="utf-8" />
|
|
37
52
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
38
|
-
<title>
|
|
39
|
-
<style>
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
.
|
|
51
|
-
.
|
|
52
|
-
|
|
53
|
+
<title>Auvrynt: Webkit Arsenal</title>
|
|
54
|
+
<style>
|
|
55
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
56
|
+
:root { color-scheme: dark; font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
|
57
|
+
body { margin: 0; min-height: 100vh; background: #080b10; color: #f4f7fb; display: grid; place-items: center; padding: 24px; }
|
|
58
|
+
main { width: min(100%, 960px); display: grid; grid-template-columns: .82fr 1.18fr; overflow: hidden; background: #10151d; border: 1px solid #273241; border-radius: 18px; box-shadow: 0 28px 90px rgba(0,0,0,.42); }
|
|
59
|
+
.identity { padding: 44px 38px; background: #0c1118; border-right: 1px solid #273241; display: flex; flex-direction: column; justify-content: space-between; min-height: 620px; }
|
|
60
|
+
.mark { display: inline-flex; align-items: center; gap: 10px; color: #a9b8c9; font-size: 12px; font-weight: 750; letter-spacing: .14em; text-transform: uppercase; }
|
|
61
|
+
.mark-icon { width: 26px; height: 26px; display: grid; place-items: center; border: 1px solid #64748b; color: #b8f36b; border-radius: 7px; font-size: 13px; }
|
|
62
|
+
.identity h1 { max-width: 310px; margin: 56px 0 16px; font-size: clamp(34px, 4vw, 52px); line-height: .98; letter-spacing: -.055em; font-weight: 760; }
|
|
63
|
+
.identity h1 span { color: #b8f36b; }
|
|
64
|
+
.identity-copy { max-width: 310px; margin: 0; color: #8e9bad; font-size: 15px; line-height: 1.65; }
|
|
65
|
+
.connection-meta { display: grid; gap: 14px; margin-top: 36px; }
|
|
66
|
+
.meta-row { display: flex; justify-content: space-between; gap: 16px; padding-top: 12px; border-top: 1px solid #273241; color: #778598; font-size: 12px; }
|
|
67
|
+
.meta-row strong { color: #d7e0eb; font-weight: 600; text-align: right; overflow-wrap: anywhere; }
|
|
68
|
+
.status { display: inline-flex; align-items: center; gap: 7px; color: #b8f36b; font-weight: 650; }
|
|
69
|
+
.status::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: #b8f36b; box-shadow: 0 0 0 3px rgba(184,243,107,.12); }
|
|
70
|
+
.identity-footer { color: #617084; font-size: 11px; line-height: 1.5; }
|
|
71
|
+
.form-panel { padding: 44px 48px; }
|
|
72
|
+
.eyebrow { margin: 0 0 12px; color: #b8f36b; font-size: 11px; font-weight: 750; letter-spacing: .14em; text-transform: uppercase; }
|
|
73
|
+
.form-panel h2 { margin: 0 0 10px; font-size: 28px; line-height: 1.1; letter-spacing: -.035em; }
|
|
74
|
+
.intro { max-width: 520px; margin: 0 0 28px; color: #9ba8b8; font-size: 15px; line-height: 1.6; }
|
|
75
|
+
.client-badge { display: inline-flex; align-items: center; gap: 8px; margin-bottom: 24px; padding: 8px 11px; border: 1px solid #344153; border-radius: 8px; color: #dce6f1; background: #171e28; font-size: 13px; }
|
|
76
|
+
.client-badge::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: #b8f36b; }
|
|
77
|
+
.section-label { margin: 0 0 10px; color: #718095; font-size: 11px; font-weight: 750; letter-spacing: .12em; text-transform: uppercase; }
|
|
78
|
+
.scope-list { list-style: none; padding: 0; margin: 0 0 28px; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
|
|
79
|
+
.scope-list li { min-height: 68px; padding: 12px 13px; border: 1px solid #2b3747; border-radius: 10px; background: #141b24; color: #8e9bad; font-size: 12px; line-height: 1.45; }
|
|
80
|
+
.scope-list li strong { display: block; margin-bottom: 4px; color: #f1f5f9; font-size: 13px; font-weight: 650; }
|
|
81
|
+
.token-group { margin-bottom: 22px; }
|
|
82
|
+
.token-group label { display: block; margin-bottom: 8px; color: #dce6f1; font-size: 13px; font-weight: 650; }
|
|
83
|
+
.input-wrapper { position: relative; }
|
|
84
|
+
.input-wrapper input { width: 100%; padding: 13px 58px 13px 14px; border: 1px solid #3a485a; border-radius: 9px; background: #0c1118; color: #f4f7fb; font: inherit; font-size: 15px; outline: none; transition: border-color .18s, box-shadow .18s; }
|
|
85
|
+
.input-wrapper input:focus { border-color: #b8f36b; box-shadow: 0 0 0 3px rgba(184,243,107,.14); }
|
|
86
|
+
.input-wrapper input::placeholder { color: #657388; }
|
|
87
|
+
.toggle-vis { position: absolute; right: 5px; top: 50%; transform: translateY(-50%); border: 0; border-radius: 6px; padding: 7px 9px; background: transparent; color: #9ba8b8; cursor: pointer; font-size: 12px; font-weight: 650; }
|
|
88
|
+
.toggle-vis:hover { color: #b8f36b; background: #1b2632; }
|
|
89
|
+
.button-group { display: flex; gap: 10px; }
|
|
90
|
+
.button-group button { flex: 1; min-height: 46px; border-radius: 9px; padding: 12px 14px; font: inherit; font-size: 14px; font-weight: 750; cursor: pointer; transition: transform .18s, background .18s, border-color .18s; }
|
|
91
|
+
.button-group button:hover { transform: translateY(-1px); }
|
|
92
|
+
.button-group button:focus-visible, .toggle-vis:focus-visible { outline: 2px solid #b8f36b; outline-offset: 3px; }
|
|
93
|
+
.btn-approve { border: 1px solid #b8f36b; color: #10150c; background: #b8f36b; }
|
|
94
|
+
.btn-approve:hover { background: #c8fa89; }
|
|
95
|
+
.btn-deny { border: 1px solid #3a485a; color: #dce6f1; background: transparent; }
|
|
96
|
+
.btn-deny:hover { border-color: #718095; background: #171e28; }
|
|
97
|
+
.error { display: block; margin: 0 0 20px; padding: 12px 14px; border: 1px solid #b45555; border-radius: 9px; color: #ffd1d1; background: #321a1e; font-size: 13px; line-height: 1.45; }
|
|
98
|
+
.security-note { display: flex; gap: 8px; margin: 22px 0 0; color: #718095; font-size: 12px; line-height: 1.5; }
|
|
99
|
+
.security-note::before { content: "✓"; color: #b8f36b; font-weight: 800; }
|
|
100
|
+
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { transition: none !important; } .button-group button:hover { transform: none; } }
|
|
101
|
+
@media (max-width: 720px) { body { padding: 12px; place-items: start center; } main { grid-template-columns: 1fr; } .identity { min-height: auto; padding: 28px 24px; border-right: 0; border-bottom: 1px solid #273241; } .identity h1 { margin: 34px 0 12px; font-size: 40px; } .identity-copy { max-width: 520px; } .connection-meta { margin-top: 24px; } .identity-footer { display: none; } .form-panel { padding: 28px 24px 32px; } }
|
|
102
|
+
@media (max-width: 480px) { .scope-list { grid-template-columns: 1fr; } .button-group { flex-direction: column-reverse; } }
|
|
103
|
+
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
|
|
104
|
+
</style>
|
|
53
105
|
</head>
|
|
54
106
|
<body>
|
|
55
|
-
<main>
|
|
56
|
-
<
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
<
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
107
|
+
<main role="main">
|
|
108
|
+
<section class="identity" aria-label="Auvrynt connection details">
|
|
109
|
+
<div>
|
|
110
|
+
<div class="mark"><span class="mark-icon" aria-hidden="true">A</span> Auvrynt</div>
|
|
111
|
+
<h1>Local power.<br /><span>Explicit access.</span></h1>
|
|
112
|
+
<p class="identity-copy">Auvrynt keeps your development workspace local while giving your web agent a controlled MCP connection.</p>
|
|
113
|
+
<div class="connection-meta" aria-label="Connection summary">
|
|
114
|
+
<div class="meta-row"><span>Requesting agent</span><strong>${htmlEscape(params.clientName)}</strong></div>
|
|
115
|
+
<div class="meta-row"><span>Connection state</span><strong class="status">Awaiting approval</strong></div>
|
|
116
|
+
<div class="meta-row"><span>Permissions</span><strong>${params.scopes.length} requested</strong></div>
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
<p class="identity-footer">Review every permission before continuing. You can deny this request without changing your workspace.</p>
|
|
120
|
+
</section>
|
|
121
|
+
<section class="form-panel" aria-labelledby="authorize-title">
|
|
122
|
+
<p class="eyebrow">Secure handshake</p>
|
|
123
|
+
<h2 id="authorize-title">Approve this connection</h2>
|
|
124
|
+
<p class="intro">Review what the web agent can do in your workspace, then confirm with your local owner token.</p>
|
|
125
|
+
${error}
|
|
126
|
+
<div class="client-badge">${htmlEscape(params.clientName)}</div>
|
|
127
|
+
<p class="section-label">Requested permissions</p>
|
|
128
|
+
<ul class="scope-list" aria-label="Requested permissions">
|
|
129
|
+
${scopeItems}
|
|
130
|
+
</ul>
|
|
131
|
+
<form method="post" aria-describedby="token-desc">
|
|
132
|
+
<div class="token-group">
|
|
133
|
+
<label for="owner_token">Local owner token</label>
|
|
134
|
+
<p id="token-desc" class="sr-only">Enter your Auvrynt owner token to authorize this connection. This token stays on this page and is never shared with the web agent.</p>
|
|
135
|
+
<div class="input-wrapper">
|
|
136
|
+
<input id="owner_token" name="owner_token" type="password" placeholder="Enter your owner token" autocomplete="off" autofocus required aria-required="true" />
|
|
137
|
+
<button type="button" class="toggle-vis" aria-label="Show owner token" data-target="owner_token">Show</button>
|
|
138
|
+
</div>
|
|
139
|
+
</div>
|
|
140
|
+
${hiddenFields}
|
|
141
|
+
<div class="button-group">
|
|
142
|
+
<button type="submit" name="denied" value="true" class="btn-deny" formnovalidate>Deny request</button>
|
|
143
|
+
<button type="submit" class="btn-approve">Approve connection</button>
|
|
144
|
+
</div>
|
|
145
|
+
</form>
|
|
146
|
+
<p class="security-note">Your owner token is validated locally. It is never sent to the web agent, stored in cookies, or written to logs.</p>
|
|
147
|
+
</section>
|
|
148
|
+
</main>
|
|
149
|
+
<script nonce="${randomUUID()}">
|
|
150
|
+
(function() {
|
|
151
|
+
var btn = document.querySelector('.toggle-vis');
|
|
152
|
+
var input = document.getElementById('owner_token');
|
|
153
|
+
if (btn && input) {
|
|
154
|
+
btn.addEventListener('click', function() {
|
|
155
|
+
var isPassword = input.type === 'password';
|
|
156
|
+
input.type = isPassword ? 'text' : 'password';
|
|
157
|
+
btn.textContent = isPassword ? 'Hide' : 'Show';
|
|
158
|
+
btn.setAttribute('aria-label', isPassword ? 'Hide owner token' : 'Show owner token');
|
|
159
|
+
input.setAttribute('aria-live', 'polite');
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
var form = document.querySelector('form');
|
|
163
|
+
var denyBtn = document.querySelector('button[name="denied"]');
|
|
164
|
+
if (form && denyBtn) {
|
|
165
|
+
denyBtn.addEventListener('click', function(e) {
|
|
166
|
+
e.preventDefault();
|
|
167
|
+
var redirect = new URL(window.location.href);
|
|
168
|
+
redirect.searchParams.set('error', 'access_denied');
|
|
169
|
+
window.location.href = redirect.href;
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
})();
|
|
173
|
+
</script>
|
|
71
174
|
</body>
|
|
72
175
|
</html>`;
|
|
73
176
|
}
|
|
@@ -131,7 +234,12 @@ export class SingleUserOAuthProvider {
|
|
|
131
234
|
if (!requestedScopesAllowed(params.scopes ?? [], this.config.scopes)) {
|
|
132
235
|
throw new InvalidRequestError("Requested scope is not supported");
|
|
133
236
|
}
|
|
134
|
-
|
|
237
|
+
const req = res.req;
|
|
238
|
+
const queryError = req.query?.error;
|
|
239
|
+
if (queryError === "access_denied") {
|
|
240
|
+
throw new AccessDeniedError("Authorization denied by user");
|
|
241
|
+
}
|
|
242
|
+
if (req.method !== "POST") {
|
|
135
243
|
res.status(200).setHeader("Content-Type", "text/html; charset=utf-8");
|
|
136
244
|
res.send(formHtml({
|
|
137
245
|
clientName: client.client_name ?? client.client_id,
|
|
@@ -141,11 +249,14 @@ export class SingleUserOAuthProvider {
|
|
|
141
249
|
}));
|
|
142
250
|
return;
|
|
143
251
|
}
|
|
144
|
-
|
|
252
|
+
if (req.body?.denied === "true") {
|
|
253
|
+
throw new AccessDeniedError("Authorization denied by user");
|
|
254
|
+
}
|
|
255
|
+
const providedToken = String(req.body?.owner_token ?? "");
|
|
145
256
|
if (!safeEquals(providedToken, this.config.ownerToken)) {
|
|
146
257
|
res.status(401).setHeader("Content-Type", "text/html; charset=utf-8");
|
|
147
258
|
res.send(formHtml({
|
|
148
|
-
error: "The
|
|
259
|
+
error: "The owner password was not accepted.",
|
|
149
260
|
clientName: client.client_name ?? client.client_id,
|
|
150
261
|
scopes: params.scopes ?? this.config.scopes,
|
|
151
262
|
resource: params.resource,
|
package/dist/server.js
CHANGED
|
@@ -23,6 +23,7 @@ import { formatAgentsPath, WorkspaceRegistry } from "./workspaces.js";
|
|
|
23
23
|
import { executeViewImage } from "./view-image.js";
|
|
24
24
|
import { ProcessManager } from "./processes.js";
|
|
25
25
|
import { getConnectionStatus } from "./connection-status.js";
|
|
26
|
+
import { recordConnectedClient } from "./connection-registry.js";
|
|
26
27
|
import { globFiles, searchText, inspectProject } from "./search-discovery.js";
|
|
27
28
|
import { capturePageScreenshot, inspectPage, startDevServer } from "./web-tools.js";
|
|
28
29
|
import { inspectImage, compareImages, inspectSprite, splitSpriteSheet } from "./image-tools.js";
|
|
@@ -190,6 +191,14 @@ function requestLogFields(req, config) {
|
|
|
190
191
|
contentLength: req.header("content-length"),
|
|
191
192
|
};
|
|
192
193
|
}
|
|
194
|
+
function mcpClientName(req) {
|
|
195
|
+
const headerName = req.header("x-mcp-client-name") ?? req.header("x-client-name");
|
|
196
|
+
if (headerName)
|
|
197
|
+
return headerName;
|
|
198
|
+
const body = req.body;
|
|
199
|
+
const name = body?.params?.clientInfo?.name;
|
|
200
|
+
return typeof name === "string" ? name : undefined;
|
|
201
|
+
}
|
|
193
202
|
function logToolCall(config, fields) {
|
|
194
203
|
if (!config.logging.toolCalls)
|
|
195
204
|
return;
|
|
@@ -2666,9 +2675,10 @@ export function createServer(config = loadConfig()) {
|
|
|
2666
2675
|
const mcpUrl = new URL("/mcp", config.publicBaseUrl);
|
|
2667
2676
|
const resourceServerUrl = resourceUrlFromServerUrl(mcpUrl);
|
|
2668
2677
|
const oauthProvider = new SingleUserOAuthProvider(config.oauth, mcpUrl);
|
|
2678
|
+
const minimumScope = config.oauth.scopes.includes("auvrynt:read") ? "auvrynt:read" : config.oauth.scopes[0];
|
|
2669
2679
|
const bearerAuth = requireBearerAuth({
|
|
2670
2680
|
verifier: oauthProvider,
|
|
2671
|
-
requiredScopes: [
|
|
2681
|
+
requiredScopes: [minimumScope],
|
|
2672
2682
|
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(resourceServerUrl),
|
|
2673
2683
|
});
|
|
2674
2684
|
const workspaceStore = createWorkspaceStore(config.stateDir);
|
|
@@ -2755,6 +2765,18 @@ export function createServer(config = loadConfig()) {
|
|
|
2755
2765
|
sendJsonRpcError(res, 401, -32001, "Unauthorized");
|
|
2756
2766
|
return;
|
|
2757
2767
|
}
|
|
2768
|
+
try {
|
|
2769
|
+
recordConnectedClient(config.stateDir, {
|
|
2770
|
+
clientName: mcpClientName(req),
|
|
2771
|
+
userAgent: req.header("user-agent") ?? undefined,
|
|
2772
|
+
});
|
|
2773
|
+
}
|
|
2774
|
+
catch (error) {
|
|
2775
|
+
logEvent(config.logging, "warn", "connection_registry_write_failed", {
|
|
2776
|
+
requestId,
|
|
2777
|
+
error: error instanceof Error ? error.message : String(error),
|
|
2778
|
+
});
|
|
2779
|
+
}
|
|
2758
2780
|
logEvent(config.logging, "debug", "mcp_request", {
|
|
2759
2781
|
requestId,
|
|
2760
2782
|
method: req.method,
|
|
@@ -6,11 +6,16 @@ Auvrynt extends Model Context Protocol (MCP) with tools for web application deve
|
|
|
6
6
|
|
|
7
7
|
## Architecture & Security Boundaries
|
|
8
8
|
|
|
9
|
-
Use `get_connection_status` after `open_workspace` to automatically probe the
|
|
10
|
-
authenticated MCP session,
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
9
|
+
Use `get_connection_status` after `open_workspace` to automatically probe the
|
|
10
|
+
authenticated MCP session, Blender, Godot, Cloudflare Tunnel, Serena, browser
|
|
11
|
+
support, Chrome MCP registration, and tracked workspace processes. Blender
|
|
12
|
+
detection includes the Blender Lab MCP endpoint shown in Blender preferences
|
|
13
|
+
(`localhost:9876` by default) and the Auvrynt bridge (`127.0.0.1:49323`).
|
|
14
|
+
Godot detection distinguishes a running Godot process from a reachable Auvrynt
|
|
15
|
+
editor bridge (`127.0.0.1:49322`). Cloudflare Tunnel and Serena report whether
|
|
16
|
+
their executables are installed and whether their processes are running. A
|
|
17
|
+
browser marked `available` means Playwright is installed; browser sessions are
|
|
18
|
+
created on demand and are not persistent connections.
|
|
14
19
|
|
|
15
20
|
1. **Workspace Boundary Enforcement**: Every path input must be relative to the opened workspace root. Path traversal (`..`) or symbolic links escaping the workspace are blocked by `WorkspaceRegistry.resolvePath`.
|
|
16
21
|
2. **Persistent Processes**: Long-running servers, .NET applications, and Godot games run as background child processes managed by `ProcessManager`. Standard output and error streams are captured in bounded in-memory log buffers (max 1000 lines).
|