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/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, ["auvrynt"]),
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
+ }
@@ -21,10 +21,25 @@ function htmlEscape(value) {
21
21
  .replaceAll("'", "'");
22
22
  }
23
23
  function formHtml(params) {
24
- const scopeText = params.scopes.length > 0 ? params.scopes.join(" ") : "auvrynt";
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
- ? `<p class="error">${htmlEscape(params.error)}</p>`
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>Connect Auvrynt</title>
39
- <style>
40
- body { font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 0; background: #0f172a; color: #e2e8f0; }
41
- main { max-width: 440px; margin: 12vh auto; padding: 32px; background: #111827; border: 1px solid #334155; border-radius: 18px; box-shadow: 0 24px 80px rgba(0,0,0,.35); }
42
- h1 { margin: 0 0 12px; font-size: 28px; }
43
- p { line-height: 1.5; color: #cbd5e1; }
44
- dl { padding: 16px; background: #020617; border-radius: 12px; }
45
- dt { color: #94a3b8; font-size: 12px; text-transform: uppercase; letter-spacing: .06em; }
46
- dd { margin: 4px 0 12px; word-break: break-word; }
47
- label { display: block; margin: 18px 0 8px; font-weight: 600; }
48
- input { box-sizing: border-box; width: 100%; padding: 12px 14px; border-radius: 10px; border: 1px solid #475569; background: #020617; color: #e2e8f0; font-size: 16px; }
49
- button { margin-top: 18px; width: 100%; border: 0; border-radius: 10px; padding: 12px 14px; font-weight: 700; color: #020617; background: #38bdf8; cursor: pointer; }
50
- .error { color: #fecaca; background: #7f1d1d; border-radius: 10px; padding: 10px 12px; }
51
- .warning { color: #fde68a; }
52
- </style>
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
- <h1>Connect Auvrynt</h1>
57
- <p class="warning">Only approve this if you are intentionally connecting your own ChatGPT or MCP client to this local machine.</p>
58
- ${error}
59
- <dl>
60
- <dt>Client</dt><dd>${htmlEscape(params.clientName)}</dd>
61
- <dt>Scope</dt><dd>${htmlEscape(scopeText)}</dd>
62
- <dt>Resource</dt><dd>${htmlEscape(resourceText)}</dd>
63
- </dl>
64
- <form method="post">
65
- ${hiddenFields}
66
- <label for="owner_token">Owner password</label>
67
- <input id="owner_token" name="owner_token" type="password" autocomplete="current-password" autofocus required />
68
- <button type="submit">Authorize Auvrynt</button>
69
- </form>
70
- </main>
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
- if (res.req.method !== "POST") {
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
- const providedToken = String(res.req.body?.owner_token ?? "");
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 Owner password was not accepted.",
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: [config.oauth.scopes[0] ?? "auvrynt"],
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, the local Blender bridge, the Godot editor bridge,
11
- browser support, Chrome MCP registration, and tracked workspace processes. A
12
- browser marked `available` means Playwright is installed; browser sessions are
13
- created on demand and are not persistent connections.
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).