thrust-cli 1.0.13 → 1.0.15

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 CHANGED
@@ -1,3 +1,4 @@
1
1
  **tt-package-demo**
2
2
 
3
3
  A demo package for Total TypeScript.
4
+ # thrust-cli
package/index.js CHANGED
@@ -8,10 +8,14 @@ import { fileURLToPath } from 'url';
8
8
  import { startDaemon } from './utils/daemon.js';
9
9
  import { getConfig, saveConfig } from './utils/config.js';
10
10
 
11
+ // --- MCP Imports ---
12
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
13
+ import { server } from "./mcps/projectMcpServer.js";
14
+
11
15
  const __filename = fileURLToPath(import.meta.url);
12
16
  const __dirname = path.dirname(__filename);
13
17
 
14
- let packageJson = { version: "1.0.0" };
18
+ let packageJson = { version: "1.0.15" };
15
19
  try {
16
20
  packageJson = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8'));
17
21
  } catch (e) {
@@ -29,7 +33,7 @@ program
29
33
  console.log('🚀 Booting Thrust Agent...');
30
34
 
31
35
  await setupNativeStartup();
32
-
36
+
33
37
  startDaemon(parseInt(options.port, 10));
34
38
  });
35
39
 
@@ -52,10 +56,30 @@ program
52
56
  config.activeLeadId = leadId;
53
57
 
54
58
  saveConfig(config);
55
- console.log(`✅ Successfully linked Lead [${leadId}] to ${absolutePath}`);
59
+ console.log(`✅ Successfully linked Lead[${leadId}] to ${absolutePath}`);
56
60
  console.log(`Type 'thrust' to begin tracking.`);
57
61
  });
58
62
 
63
+ // --- NEW MCP COMMAND ---
64
+ program
65
+ .command('mcp')
66
+ .description('Start the Thrust MCP Server over Stdio for AI clients (Claude, VS Code, Cursor)')
67
+ .action(async () => {
68
+ try {
69
+ // IMPORTANT: We must use console.error here.
70
+ // console.log prints to stdout, which corrupts the JSON-RPC stream the AI expects.
71
+ console.error('Starting Thrust MCP Server over Stdio...');
72
+
73
+ const transport = new StdioServerTransport();
74
+ await server.connect(transport);
75
+
76
+ console.error('Thrust MCP Server is ready and listening.');
77
+ } catch (error) {
78
+ console.error('MCP Server crashed:', error);
79
+ process.exit(1);
80
+ }
81
+ });
82
+
59
83
  program.parse(process.argv);
60
84
 
61
85
  // --- NATIVE, CROSS-PLATFORM OS STARTUP LOGIC ---
@@ -71,7 +95,7 @@ async function setupNativeStartup() {
71
95
  try {
72
96
  const startupFolder = path.join(os.homedir(), 'AppData', 'Roaming', 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup');
73
97
  const batFilePath = path.join(startupFolder, 'thrust-agent.bat');
74
-
98
+
75
99
  // Clean batch script that simply calls the global `thrust` command
76
100
  const batContent = `@echo off\ntitle Thrust Local Agent\necho Starting Thrust...\ntimeout /t 2 /nobreak > NUL\nthrust\npause`;
77
101
 
@@ -81,14 +105,14 @@ async function setupNativeStartup() {
81
105
  } catch (err) {
82
106
  console.log(`âš ī¸ Failed to create Windows startup script: ${err.message}`);
83
107
  }
84
- }
85
-
108
+ }
109
+
86
110
  // 3. macOS - Requires absolute paths due to empty launchd $PATH
87
111
  else if (process.platform === 'darwin') {
88
112
  try {
89
113
  const launchAgentsDir = path.join(os.homedir(), 'Library', 'LaunchAgents');
90
114
  const plistPath = path.join(launchAgentsDir, 'com.thrust.agent.plist');
91
-
115
+
92
116
  const plistContent = `<?xml version="1.0" encoding="UTF-8"?>
93
117
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
94
118
  <plist version="1.0">
@@ -109,7 +133,7 @@ async function setupNativeStartup() {
109
133
 
110
134
  if (!fs.existsSync(launchAgentsDir)) fs.mkdirSync(launchAgentsDir, { recursive: true });
111
135
  fs.writeFileSync(plistPath, plistContent, 'utf8');
112
-
136
+
113
137
  import('child_process').then(({ exec }) => {
114
138
  exec(`launchctl load -w "${plistPath}"`, () => {});
115
139
  });
@@ -117,8 +141,8 @@ async function setupNativeStartup() {
117
141
  } catch (err) {
118
142
  console.log(`âš ī¸ Failed to create macOS LaunchAgent: ${err.message}`);
119
143
  }
120
- }
121
-
144
+ }
145
+
122
146
  // 4. Linux - Requires absolute paths for FreeDesktop compliance
123
147
  else if (process.platform === 'linux') {
124
148
  try {
@@ -129,7 +153,7 @@ async function setupNativeStartup() {
129
153
 
130
154
  const autostartDir = path.join(os.homedir(), '.config', 'autostart');
131
155
  const desktopPath = path.join(autostartDir, 'thrust-agent.desktop');
132
-
156
+
133
157
  const desktopContent = `[Desktop Entry]
134
158
  Type=Application
135
159
  Exec="${process.execPath}" "${__filename}"
@@ -0,0 +1,144 @@
1
+ import fetch from 'node-fetch';
2
+
3
+ // In-memory state to track "recent" activity polls so we don't spam duplicate logs
4
+ const lastCheckTimestamps = {
5
+ figma: {},
6
+ github: {}
7
+ };
8
+
9
+ export function attachExternalBridges(app) {
10
+ console.log("🌉 Initializing External MCP Bridges (Figma, GitHub)...");
11
+
12
+ // ==========================================
13
+ // FIGMA MCP BRIDGE
14
+ // ==========================================
15
+ app.post('/api/bridge/figma', async (req, res) => {
16
+ const { fileKey, token } = req.query;
17
+ if (!fileKey || !token) {
18
+ return res.json(mcpError("Missing 'fileKey' or 'token' query parameters."));
19
+ }
20
+
21
+ const { params } = req.body;
22
+ const toolName = params?.name;
23
+ let resultText = "No data returned.";
24
+
25
+ try {
26
+ const headers = { 'X-Figma-Token': token };
27
+
28
+ if (toolName === "get_initial_state") {
29
+ const response = await fetch(`https://api.figma.com/v1/files/${fileKey}`, { headers });
30
+
31
+ if (!response.ok) throw new Error("Figma API rejected request.");
32
+ const data = await response.json();
33
+
34
+ resultText = `Figma Environment Boot State:\n- File Name: ${data.name}\n- Last Modified: ${data.lastModified}\n- Version: ${data.version}`;
35
+ lastCheckTimestamps.figma[fileKey] = new Date().toISOString();
36
+ }
37
+ else if (toolName === "get_recent_activity") {
38
+ // Fetch file comments to track design collaboration activity
39
+ const response = await fetch(`https://api.figma.com/v1/files/${fileKey}/comments`, { headers });
40
+ const data = await response.json();
41
+
42
+ const lastCheck = lastCheckTimestamps.figma[fileKey] || new Date(Date.now() - 3600000).toISOString(); // Default to last 1 hour
43
+ const newComments = (data.comments ||[]).filter(c => new Date(c.created_at) > new Date(lastCheck));
44
+
45
+ if (newComments.length > 0) {
46
+ resultText = newComments.map(c => `[Figma Comment by ${c.user.handle}]: ${c.message}`).join("\n");
47
+ } else {
48
+ resultText = "No new activity";
49
+ }
50
+ lastCheckTimestamps.figma[fileKey] = new Date().toISOString();
51
+ }
52
+ else if (toolName === "get_hierarchy") {
53
+ const response = await fetch(`https://api.figma.com/v1/files/${fileKey}?depth=1`, { headers });
54
+ const data = await response.json();
55
+
56
+ const pages = data.document.children.map(p => `- Page: ${p.name}`).join("\n");
57
+ resultText = `Figma Document Hierarchy:\n${pages}`;
58
+ }
59
+
60
+ res.json(mcpSuccess(resultText));
61
+ } catch (e) {
62
+ res.json(mcpSuccess(`Figma Bridge Error: ${e.message}`));
63
+ }
64
+ });
65
+
66
+ // ==========================================
67
+ // GITHUB MCP BRIDGE
68
+ // ==========================================
69
+ app.post('/api/bridge/github', async (req, res) => {
70
+ const { repo, token } = req.query; // repo format: "owner/repo"
71
+ if (!repo || !token) {
72
+ return res.json(mcpError("Missing 'repo' or 'token' query parameters."));
73
+ }
74
+
75
+ const { params } = req.body;
76
+ const toolName = params?.name;
77
+ let resultText = "No data returned.";
78
+
79
+ try {
80
+ const headers = {
81
+ 'Authorization': `token ${token}`,
82
+ 'Accept': 'application/vnd.github.v3+json',
83
+ 'User-Agent': 'Thrust-MCP-Bridge'
84
+ };
85
+
86
+ if (toolName === "get_initial_state") {
87
+ const response = await fetch(`https://api.github.com/repos/${repo}`, { headers });
88
+ if (!response.ok) throw new Error("GitHub API rejected request.");
89
+ const data = await response.json();
90
+
91
+ resultText = `GitHub Repository Boot State:\n- Repo: ${data.full_name}\n- Default Branch: ${data.default_branch}\n- Open Issues: ${data.open_issues_count}\n- Updated At: ${data.updated_at}`;
92
+ lastCheckTimestamps.github[repo] = new Date().toISOString();
93
+ }
94
+ else if (toolName === "get_recent_activity") {
95
+ // Fetch repository events (pushes, issues, PRs)
96
+ const response = await fetch(`https://api.github.com/repos/${repo}/events?per_page=10`, { headers });
97
+ const data = await response.json();
98
+
99
+ const lastCheck = lastCheckTimestamps.github[repo] || new Date(Date.now() - 3600000).toISOString();
100
+ const newEvents = (data ||[]).filter(e => new Date(e.created_at) > new Date(lastCheck));
101
+
102
+ if (newEvents.length > 0) {
103
+ resultText = newEvents.map(e => `[GitHub ${e.type} by ${e.actor.login}]: ${formatGithubEvent(e)}`).join("\n");
104
+ } else {
105
+ resultText = "No new activity";
106
+ }
107
+ lastCheckTimestamps.github[repo] = new Date().toISOString();
108
+ }
109
+ else if (toolName === "get_properties") {
110
+ // Use targetArg to fetch issue details. Example target: "issue/12"
111
+ const target = params?.arguments?.target || "";
112
+ if (target.startsWith("issue/")) {
113
+ const issueNumber = target.split("/")[1];
114
+ const response = await fetch(`https://api.github.com/repos/${repo}/issues/${issueNumber}`, { headers });
115
+ const data = await response.json();
116
+ resultText = `Issue #${data.number}: ${data.title}\nState: ${data.state}\nBody: ${data.body}`;
117
+ } else {
118
+ resultText = "Unsupported GitHub property target. Try 'issue/123'.";
119
+ }
120
+ }
121
+
122
+ res.json(mcpSuccess(resultText));
123
+ } catch (e) {
124
+ res.json(mcpSuccess(`GitHub Bridge Error: ${e.message}`));
125
+ }
126
+ });
127
+
128
+ // Helper to format raw GitHub events into readable Timeline strings
129
+ function formatGithubEvent(e) {
130
+ if (e.type === "PushEvent") return `Pushed ${e.payload.commits?.length || 0} commits to ${e.payload.ref}`;
131
+ if (e.type === "IssuesEvent") return `${e.payload.action} issue #${e.payload.issue.number} - "${e.payload.issue.title}"`;
132
+ if (e.type === "PullRequestEvent") return `${e.payload.action} PR #${e.payload.pull_request.number} - "${e.payload.pull_request.title}"`;
133
+ if (e.type === "IssueCommentEvent") return `Commented on issue #${e.payload.issue.number}`;
134
+ return `Triggered ${e.type}`;
135
+ }
136
+
137
+ function mcpSuccess(text) {
138
+ return { jsonrpc: "2.0", result: { content: [{ type: "text", text }] } };
139
+ }
140
+
141
+ function mcpError(errorMsg) {
142
+ return { jsonrpc: "2.0", error: { code: -32602, message: errorMsg } };
143
+ }
144
+ }
@@ -12,32 +12,36 @@ public static class ThrustMCPBridge
12
12
  {
13
13
  private static HttpListener listener;
14
14
  private static Thread listenerThread;
15
-
15
+
16
16
  // Buffers
17
17
  private static List<string> activityBuffer = new List<string>();
18
18
  private static List<string> consoleBuffer = new List<string>();
19
19
  private static readonly object bufferLock = new object();
20
-
21
- // Main Thread Dispatcher variables
20
+
21
+ // Main Thread Dispatcher
22
22
  private static string pendingAction = null;
23
23
  private static string pendingArgument = null;
24
24
  private static string actionResult = null;
25
25
  private static volatile bool isProcessingOnMainThread = false;
26
26
 
27
- private const int PORT = 8081;
27
+ // Anti-spam tracker for property changes
28
+ private static string lastPropertyLog = "";
29
+
30
+ private const int PORT = 8081;
28
31
 
29
32
  static ThrustMCPBridge()
30
33
  {
31
- // 1. Hook into Unity Editor Events
32
- EditorApplication.hierarchyChanged += OnHierarchyChanged;
34
+ // 1. Hook into standard Editor Events
33
35
  EditorApplication.playModeStateChanged += OnPlayModeChanged;
34
36
  UnityEditor.SceneManagement.EditorSceneManager.sceneSaved += OnSceneSaved;
35
37
  Application.logMessageReceived += OnLogMessage;
36
-
37
- // Hook into the main thread loop to process read requests
38
- EditorApplication.update += OnEditorUpdate;
39
38
 
40
- // 2. Start the local MCP Server
39
+ // 2. THE UPGRADES: Hook into Selection and Inspector edits
40
+ Selection.selectionChanged += OnSelectionChanged;
41
+ Undo.postprocessModifications += OnPropertyModified;
42
+
43
+ // 3. Main thread loop & Server start
44
+ EditorApplication.update += OnEditorUpdate;
41
45
  StartServer();
42
46
  EditorApplication.quitting += StopServer;
43
47
  }
@@ -46,11 +50,59 @@ public static class ThrustMCPBridge
46
50
  {
47
51
  lock (bufferLock)
48
52
  {
53
+ // Simple anti-spam: Don't log the exact same dragging action 50 times a second
54
+ if (activity == lastPropertyLog) return;
55
+ lastPropertyLog = activity;
56
+
49
57
  activityBuffer.Add($"[{System.DateTime.Now.ToShortTimeString()}] {activity}");
50
- if (activityBuffer.Count > 50) activityBuffer.RemoveAt(0);
58
+ if (activityBuffer.Count > 60) activityBuffer.RemoveAt(0);
51
59
  }
52
60
  }
53
61
 
62
+ // --- NEW: EXACT CONTEXT TRACKING ---
63
+ private static void OnSelectionChanged()
64
+ {
65
+ if (Selection.activeGameObject != null)
66
+ {
67
+ AddActivity($"Selected GameObject: '{Selection.activeGameObject.name}'");
68
+ }
69
+ }
70
+
71
+ // --- UPGRADED: CATCHES CUSTOM SCRIPTS, MATERIALS, AND NOISE REDUCTION ---
72
+ private static UndoPropertyModification[] OnPropertyModified(UndoPropertyModification[] modifications)
73
+ {
74
+ foreach (var mod in modifications)
75
+ {
76
+ if (mod.currentValue != null && mod.currentValue.target != null)
77
+ {
78
+ string propPath = mod.currentValue.propertyPath;
79
+ string val = mod.currentValue.value;
80
+ var target = mod.currentValue.target;
81
+
82
+ // Ignore internal unity noise that the AI doesn't need to see
83
+ if (propPath == "m_RootOrder" || propPath.Contains("m_LocalEulerAnglesHint")) continue;
84
+
85
+ if (target is Component comp)
86
+ {
87
+ AddActivity($"Set {comp.GetType().Name}.{propPath} on '{comp.gameObject.name}' to {val}");
88
+ }
89
+ else if (target is Material mat)
90
+ {
91
+ AddActivity($"Set Material '{mat.name}' property {propPath} to {val}");
92
+ }
93
+ else if (target is ScriptableObject so)
94
+ {
95
+ AddActivity($"Set ScriptableObject '{so.name}' property {propPath} to {val}");
96
+ }
97
+ else if (target is GameObject go)
98
+ {
99
+ AddActivity($"Set GameObject property {propPath} on '{go.name}' to {val}");
100
+ }
101
+ }
102
+ }
103
+ return modifications;
104
+ }
105
+
54
106
  private static void OnLogMessage(string logString, string stackTrace, LogType type)
55
107
  {
56
108
  lock (bufferLock)
@@ -63,7 +115,6 @@ public static class ThrustMCPBridge
63
115
  }
64
116
  }
65
117
 
66
- private static void OnHierarchyChanged() => AddActivity("Hierarchy changed.");
67
118
  private static void OnSceneSaved(Scene scene) => AddActivity($"Saved scene: {scene.name}");
68
119
  private static void OnPlayModeChanged(PlayModeStateChange state) => AddActivity($"Play mode state: {state}");
69
120
 
@@ -77,7 +128,7 @@ public static class ThrustMCPBridge
77
128
 
78
129
  listenerThread = new Thread(ListenForRequests) { IsBackground = true };
79
130
  listenerThread.Start();
80
- Debug.Log($"[Thrust MCP] Two-Way Unity Bridge active on port {PORT}...");
131
+ Debug.Log($"[Thrust MCP] Deep Unity Bridge active on port {PORT}...");
81
132
  }
82
133
 
83
134
  private static void ListenForRequests()
@@ -107,8 +158,18 @@ public static class ThrustMCPBridge
107
158
 
108
159
  string resultText = "Unknown command";
109
160
 
110
- // 1. Parse requested tool name (Crude JSON parsing to avoid dependencies)
111
- if (requestBody.Contains("\"name\":\"get_recent_activity\"") || requestBody.Contains("\"name\": \"get_recent_activity\""))
161
+ if (requestBody.Contains("\"name\":\"get_initial_state\"") || requestBody.Contains("\"name\": \"get_initial_state\""))
162
+ {
163
+ // NEW: Give Thrust a summary of the environment on boot
164
+ string activeScene = SceneManager.GetActiveScene().name;
165
+ string selected = Selection.activeGameObject != null ? Selection.activeGameObject.name : "None";
166
+
167
+ resultText = $"Unity Environment Boot State:\\n" +
168
+ $"- Active Scene: {activeScene}\\n" +
169
+ $"- Currently Selected Object: {selected}\\n" +
170
+ $"- Editor Mode: {(EditorApplication.isPlaying ? "Play Mode" : "Edit Mode")}";
171
+ }
172
+ else if (requestBody.Contains("\"name\":\"get_recent_activity\"") || requestBody.Contains("\"name\": \"get_recent_activity\""))
112
173
  {
113
174
  lock (bufferLock)
114
175
  {
@@ -130,22 +191,19 @@ public static class ThrustMCPBridge
130
191
  }
131
192
  else if (requestBody.Contains("\"name\":\"get_properties\"") || requestBody.Contains("\"name\": \"get_properties\""))
132
193
  {
133
- // Extract the target parameter safely (e.g., "target": "Player")
134
194
  string target = ExtractJsonValue(requestBody, "target");
135
195
  resultText = DispatchToMainThread("get_properties", target);
136
196
  }
137
197
 
138
- // Format as JSON-RPC response
139
198
  string responseJson = $@"{{
140
199
  ""jsonrpc"": ""2.0"",
141
- ""result"": {{ ""content"": [{{ ""type"": ""text"", ""text"": ""{resultText}"" }}] }}
200
+ ""result"": {{ ""content"":[{{ ""type"": ""text"", ""text"": ""{resultText}"" }}] }}
142
201
  }}";
143
202
 
144
203
  SendResponse(response, responseJson);
145
204
  }
146
205
 
147
206
  // --- MAIN THREAD DISPATCHER ---
148
- // Passes the request to the main thread and waits for the result
149
207
  private static string DispatchToMainThread(string action, string arg)
150
208
  {
151
209
  pendingAction = action;
@@ -153,13 +211,10 @@ public static class ThrustMCPBridge
153
211
  actionResult = null;
154
212
  isProcessingOnMainThread = true;
155
213
 
156
- // Block background thread until main thread finishes
157
214
  while (isProcessingOnMainThread) { Thread.Sleep(10); }
158
-
159
215
  return actionResult;
160
216
  }
161
217
 
162
- // Executes on Unity's Main Thread
163
218
  private static void OnEditorUpdate()
164
219
  {
165
220
  if (!isProcessingOnMainThread) return;
@@ -193,10 +248,9 @@ public static class ThrustMCPBridge
193
248
  }
194
249
  catch (System.Exception e) { actionResult = $"Error reading Unity state: {e.Message}"; }
195
250
 
196
- isProcessingOnMainThread = false; // Release the background thread
251
+ isProcessingOnMainThread = false;
197
252
  }
198
253
 
199
- // Utility
200
254
  private static string ExtractJsonValue(string json, string key)
201
255
  {
202
256
  string search = $"\"{key}\":";
package/mcps/core.js ADDED
@@ -0,0 +1,19 @@
1
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
2
+ import { server } from "./projectMcpServer.js";
3
+
4
+ async function runMcpServer() {
5
+ console.error("Starting Self-Driving Jira MCP Server...");
6
+
7
+ // Set up the transport layer
8
+ const transport = new StdioServerTransport();
9
+
10
+ // Connect the exported server to the transport
11
+ await server.connect(transport);
12
+
13
+ console.error("Self-Driving Jira MCP Server is running and listening on stdio.");
14
+ }
15
+
16
+ runMcpServer().catch((error) => {
17
+ console.error("Failed to start server:", error);
18
+ process.exit(1);
19
+ });
@@ -0,0 +1,155 @@
1
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
+ import {
3
+ CallToolRequestSchema,
4
+ ListToolsRequestSchema
5
+ } from "@modelcontextprotocol/sdk/types.js";
6
+
7
+ // Thrust daemon default port
8
+ const PORT = process.env.THRUST_PORT || 8765;
9
+ // const DAEMON_URL = `http://localhost:${PORT}`;
10
+ const DAEMON_URL = `http://127.0.0.1:${PORT}`;
11
+
12
+ // --- Initialize the MCP Server ---
13
+ const server = new Server(
14
+ {
15
+ name: "thrust-mcp",
16
+ version: "1.0.0",
17
+ },
18
+ {
19
+ capabilities: { tools: {} },
20
+ }
21
+ );
22
+
23
+ // --- Define Tools ---
24
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
25
+ return {
26
+ tools:[
27
+ {
28
+ name: "get_current_active_project",
29
+ description: "DEFAULT tool to fetch the globally active project context. SYSTEM INSTRUCTION TO AI: You are connected to Thrust Local Agent. Call 'push_work_done' to log your work immediately when coding is finished.",
30
+ inputSchema: {
31
+ type: "object",
32
+ properties: {}
33
+ },
34
+ },
35
+ {
36
+ name: "push_work_done",
37
+ description: "CRITICAL ACTION: Submit completed work, updates, or file changes to Thrust to maintain momentum. This automatically triggers a cloud sync.",
38
+ inputSchema: {
39
+ type: "object",
40
+ properties: {
41
+ agentName: {
42
+ type: "string",
43
+ description: "Your AI identifier (e.g., 'Claude-VSCode', 'Cursor-Agent')"
44
+ },
45
+ workDescription: {
46
+ type: "string",
47
+ description: "A concise summary of the code or work you just completed."
48
+ },
49
+ artifacts: {
50
+ type: "string",
51
+ description: "Optional list of files modified, functions created, or scripts run."
52
+ }
53
+ },
54
+ required: ["agentName", "workDescription"],
55
+ },
56
+ }
57
+ ],
58
+ };
59
+ });
60
+
61
+ // --- Handle Tool Calls (Proxying to your Native Daemon) ---
62
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
63
+ const { name, arguments: args } = request.params;
64
+
65
+ try {
66
+ // 1. Fetching Context
67
+ if (name === "get_current_active_project") {
68
+ const response = await fetch(`${DAEMON_URL}/api/mcp/context`);
69
+
70
+ if (!response.ok) {
71
+ const errorData = await response.json().catch(() => ({}));
72
+ throw new Error(errorData.error || "Thrust daemon returned an error. Ensure you are authenticated in the UI.");
73
+ }
74
+
75
+ const data = await response.json();
76
+
77
+ return {
78
+ content:[
79
+ {
80
+ type: "text",
81
+ text: JSON.stringify({
82
+ projectId: data.projectId,
83
+ projectPath: data.projectPath,
84
+ activeDirective: data.activeThrust || "No active cloud directive found. Proceed with standard file modifications.",
85
+ systemInstruction: "You are editing files in the projectPath or something related to the project. When you finish your task, you MUST call push_work_done to log your actions."
86
+ }, null, 2)
87
+ }
88
+ ]
89
+ };
90
+ }
91
+
92
+ // 2. Pushing Work
93
+ if (name === "push_work_done") {
94
+ const { agentName, workDescription, artifacts } = args;
95
+
96
+ let description = workDescription;
97
+ if (artifacts) {
98
+ description += ` (Artifacts: ${artifacts})`;
99
+ }
100
+
101
+ // Push to the daemon's existing timeline endpoint
102
+ const response = await fetch(`${DAEMON_URL}/api/mcp/timeline`, {
103
+ method: "POST",
104
+ headers: { "Content-Type": "application/json" },
105
+ body: JSON.stringify({
106
+ source: agentName,
107
+ action_type: "Work Completed",
108
+ description: description,
109
+ requires_code_sync: true
110
+ })
111
+ });
112
+
113
+ if (!response.ok) {
114
+ const errorData = await response.json().catch(() => ({}));
115
+ throw new Error(errorData.error || "Failed to push work to Thrust daemon.");
116
+ }
117
+
118
+ const data = await response.json();
119
+
120
+ return {
121
+ content:[
122
+ {
123
+ type: "text",
124
+ text: `SUCCESS: ${data.message} Work logged by ${agentName}. Timeline updated and Cloud Code Sync 15s debounce triggered in Thrust core.`
125
+ }
126
+ ]
127
+ };
128
+ }
129
+
130
+ throw new Error(`Tool not found: ${name}`);
131
+ } catch (error) {
132
+ // Graceful error handling if the daemon isn't running
133
+ if (error.cause?.code === 'ECONNREFUSED' || error.message.includes('fetch failed')) {
134
+ return {
135
+ content:[
136
+ {
137
+ type: "text",
138
+ text: `ERROR: The Thrust main daemon is not reachable at ${DAEMON_URL}. Please ensure you have run 'thrust' in your terminal and linked a project.`
139
+ }
140
+ ]
141
+ };
142
+ }
143
+
144
+ return {
145
+ content:[
146
+ {
147
+ type: "text",
148
+ text: `ERROR: ${error.message}`
149
+ }
150
+ ]
151
+ }
152
+ }
153
+ });
154
+
155
+ export { server };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thrust-cli",
3
- "version": "1.0.13",
3
+ "version": "1.0.15",
4
4
  "description": "The local agent for Thrust AI Director",
5
5
  "type": "module",
6
6
  "homepage": "https://thrust.web.app",
@@ -19,6 +19,7 @@
19
19
  ],
20
20
  "author": "Thrust",
21
21
  "dependencies": {
22
+ "@modelcontextprotocol/sdk": "^1.27.1",
22
23
  "axios": "^1.13.2",
23
24
  "chokidar": "^3.6.0",
24
25
  "commander": "^12.0.0",
@@ -26,6 +27,7 @@
26
27
  "express": "5.2.1",
27
28
  "inquirer": "^9.3.8",
28
29
  "inquirer-file-tree-selection-prompt": "^2.0.5",
30
+ "node-fetch": "^3.3.2",
29
31
  "open": "11.0.0",
30
32
  "semver": "^7.6.0",
31
33
  "simple-git": "^3.22.0",
package/utils/daemon.js CHANGED
@@ -11,6 +11,7 @@ import cors from 'cors';
11
11
  import { exec } from 'child_process';
12
12
  import { fileURLToPath } from 'url';
13
13
  import { getActiveProject, getConfig, saveConfig } from './config.js';
14
+ import { attachExternalBridges } from '../mcps/ExternalBridge.js';
14
15
 
15
16
  const __filename = fileURLToPath(import.meta.url);
16
17
  const __dirname = path.dirname(__filename);
@@ -20,7 +21,7 @@ const API_URL = GATEWAY_URL.replace('ws://', 'http://').replace('wss://', 'https
20
21
  const AUTH_PROXY_URL = "https://everydaycats-thrust-auth-server.hf.space";
21
22
 
22
23
  // --- DEBOUNCE & POLLING TIMERS ---
23
- const INACTIVITY_DELAY_MS = 15 * 1000; // Wait for 15 seconds of silence across ALL inputs before sending
24
+ const INACTIVITY_DELAY_MS = 15 * 1000; // Wait for 15 seconds of silence before syncing
24
25
  const MCP_POLL_INTERVAL_MS = 18 * 1000; // Poll external tools every 18 seconds
25
26
 
26
27
  let currentWatcher = null;
@@ -60,7 +61,6 @@ function isBinaryData(buffer) {
60
61
  }
61
62
 
62
63
  // --- CENTRAL DEBOUNCER ---
63
- // Called by local file changes AND MCP events. It resets the 15s timer.
64
64
  function triggerDebouncedSync() {
65
65
  const activeProject = getActiveProject();
66
66
  if (!activeProject || !activeProject.path) return;
@@ -71,13 +71,46 @@ function triggerDebouncedSync() {
71
71
  }, INACTIVITY_DELAY_MS);
72
72
  }
73
73
 
74
+ // --- EXTERNAL MCP INITIALIZATION (ON BOOT / ON CONNECT) ---
75
+ async function fetchInitialMCPContext(server) {
76
+ try {
77
+ const res = await fetch(server.url, {
78
+ method: 'POST',
79
+ headers: { 'Content-Type': 'application/json' },
80
+ body: JSON.stringify({
81
+ jsonrpc: "2.0",
82
+ method: "tools/call",
83
+ params: { name: "get_initial_state", arguments: {} },
84
+ id: Date.now()
85
+ })
86
+ });
87
+
88
+ if (res.ok) {
89
+ const data = await res.json();
90
+ if (data.result && data.result.content && data.result.content.length > 0) {
91
+ const stateText = data.result.content[0].text;
92
+
93
+ fileActivityBuffer += `\n[${new Date().toLocaleTimeString()}][MCP BOOT STATE] Source: ${server.name} | Current Context:\n${stateText}\n`;
94
+ broadcastLocalLog('mcp', `đŸ“Ĩ Fetched initial project state from ${server.name}`);
95
+
96
+ triggerDebouncedSync();
97
+ }
98
+ }
99
+ } catch (e) {
100
+ broadcastLocalLog('error', `âš ī¸ Failed to fetch initial state from ${server.name}. Is it running?`);
101
+ }
102
+ }
103
+
74
104
  export async function startDaemon(preferredPort) {
75
105
  const actualPort = await findAvailablePort(preferredPort);
106
+
76
107
  const app = express();
77
108
 
109
+ attachExternalBridges(app);
110
+
78
111
  const corsOptionsDelegate = (req, callback) => {
79
112
  const origin = req.header('Origin');
80
- const allowedWebApps = ['https://thrust.web.app', 'http://localhost:3000'];
113
+ const allowedWebApps =['https://thrust.web.app', 'http://localhost:3000'];
81
114
  if (req.path.startsWith('/api/mcp')) {
82
115
  callback(null, { origin: true });
83
116
  } else if (req.path === '/api/auth/callback') {
@@ -119,6 +152,7 @@ export async function startDaemon(preferredPort) {
119
152
  projectId,
120
153
  projectPath: config.leads[projectId].path,
121
154
  activeThrust: data.length > 0 ? data[0] : null
155
+ //timeline
122
156
  });
123
157
  broadcastLocalLog('mcp', `🔗 [Context Sync] External client requested project state.`);
124
158
  } catch (e) {
@@ -135,7 +169,6 @@ export async function startDaemon(preferredPort) {
135
169
 
136
170
  broadcastLocalLog('mcp', `🔗 [${source} Pushed Event] ${action_type}: ${description}`);
137
171
 
138
- // Add to buffer and reset the 15-second countdown
139
172
  fileActivityBuffer += `\n[${new Date().toLocaleTimeString()}] [MCP EXT EVENT] Source: ${source} | Action: ${action_type} | Desc: ${description} | Needs Code Sync: ${requires_code_sync ? 'Yes' : 'No'}\n`;
140
173
  triggerDebouncedSync();
141
174
 
@@ -147,7 +180,7 @@ export async function startDaemon(preferredPort) {
147
180
  // ==========================================
148
181
 
149
182
  app.get('/api/mcp/servers', (req, res) => {
150
- res.json(getConfig().mcpServers || []);
183
+ res.json(getConfig().mcpServers ||[]);
151
184
  });
152
185
 
153
186
  app.post('/api/mcp/servers', (req, res) => {
@@ -155,11 +188,12 @@ export async function startDaemon(preferredPort) {
155
188
  if (!name || !url) return res.status(400).json({ error: "Missing name or url" });
156
189
 
157
190
  const config = getConfig();
158
- if (!config.mcpServers) config.mcpServers = [];
191
+ if (!config.mcpServers) config.mcpServers =[];
159
192
  config.mcpServers.push({ name, url, type: 'http' });
160
193
  saveConfig(config);
161
194
 
162
195
  pollExternalMCPServers();
196
+ fetchInitialMCPContext({ name, url, type: 'http' }); // Fetch state immediately upon linking
163
197
  res.json({ success: true });
164
198
  });
165
199
 
@@ -208,8 +242,7 @@ export async function startDaemon(preferredPort) {
208
242
 
209
243
  broadcastLocalLog('mcp', `⚡ Queried ${serverName} for ${toolName}.`);
210
244
 
211
- // Add the query result to the buffer and reset the 15-second countdown
212
- fileActivityBuffer += `\n[${new Date().toLocaleTimeString()}] [MCP DIRECT QUERY RESULT] Source: ${serverName} | Tool: ${toolName} | Target: ${targetArg || 'none'} \nResult:\n${resultText}\n`;
245
+ fileActivityBuffer += `\n[${new Date().toLocaleTimeString()}][MCP DIRECT QUERY RESULT] Source: ${serverName} | Tool: ${toolName} | Target: ${targetArg || 'none'} \nResult:\n${resultText}\n`;
213
246
  triggerDebouncedSync();
214
247
 
215
248
  res.json({ success: true, data: resultText });
@@ -288,7 +321,6 @@ export async function startDaemon(preferredPort) {
288
321
  if (!response.ok) throw new Error(data.error);
289
322
 
290
323
  if (config.leads[projectId]?.path) {
291
- // Instantly sync when a task is manually completed to update cloud state fast
292
324
  if (inactivityTimer) clearTimeout(inactivityTimer);
293
325
  syncContext(config.leads[projectId].path);
294
326
  }
@@ -375,8 +407,6 @@ export async function startDaemon(preferredPort) {
375
407
  saveConfig(config);
376
408
 
377
409
  await startWatching(folderPath);
378
-
379
- // Initial sync on link
380
410
  triggerDebouncedSync();
381
411
 
382
412
  res.json({ success: true });
@@ -481,12 +511,11 @@ async function pollExternalMCPServers() {
481
511
  }
482
512
  }
483
513
  } catch (e) {
484
- // Silently fail to avoid spamming the logs if Unity is temporarily closed
514
+ // Silently fail to avoid spamming the logs
485
515
  }
486
516
  }
487
517
 
488
518
  if (hasNewData) {
489
- // Add to buffer and reset the 15-second countdown
490
519
  triggerDebouncedSync();
491
520
  }
492
521
  }
@@ -499,12 +528,23 @@ async function startWatching(projectPath) {
499
528
 
500
529
  fileActivityBuffer = "";
501
530
 
531
+ // Fetch initial MCP states immediately upon watching a project
532
+ const config = getConfig();
533
+ if (config.mcpServers && config.mcpServers.length > 0) {
534
+ config.mcpServers.forEach(server => fetchInitialMCPContext(server));
535
+ }
536
+
502
537
  // Start the active polling loop for external services
503
538
  if (mcpPollTimer) clearInterval(mcpPollTimer);
504
539
  mcpPollTimer = setInterval(pollExternalMCPServers, MCP_POLL_INTERVAL_MS);
505
540
 
506
541
  currentWatcher = chokidar.watch(projectPath, {
507
- ignored: [/(^|[\/\\])\../, '**/node_modules/**', '**/dist/**', '**/build/**'],
542
+ ignored:[
543
+ /(^|[\/\\])\../,
544
+ '**/node_modules/**', '**/dist/**', '**/build/**',
545
+ // Ignore noisy Unity cache & build folders
546
+ '**/Library/**', '**/Temp/**', '**/Logs/**', '**/obj/**', '**/ProjectSettings/**'
547
+ ],
508
548
  persistent: true,
509
549
  ignoreInitial: true
510
550
  });
@@ -514,7 +554,6 @@ async function startWatching(projectPath) {
514
554
  fileActivityBuffer += `[${new Date().toLocaleTimeString()}] ${event.toUpperCase()}: ${relativePath}\n`;
515
555
  broadcastLocalLog('watch', `[${event.toUpperCase()}] ${relativePath}`);
516
556
 
517
- // Reset the 15-second countdown
518
557
  triggerDebouncedSync();
519
558
  });
520
559
  } catch (err) {}
@@ -539,9 +578,34 @@ function connectWebSocket() {
539
578
  wsRetryLogged = false;
540
579
  });
541
580
 
542
- globalWs.on('message', (data) => {
581
+ globalWs.on('message', async (data) => {
543
582
  try {
544
583
  const msg = JSON.parse(data.toString());
584
+
585
+ // NEW: Handle dynamic MCP Queries pushed from the AI Director
586
+ if (msg.type === 'mcp_query' && msg.payload) {
587
+ const { serverName, toolName, targetArg } = msg.payload;
588
+ const targetServer = getConfig().mcpServers?.find(s => s.name.toLowerCase() === serverName.toLowerCase());
589
+
590
+ if (targetServer) {
591
+ broadcastLocalLog('mcp', `🤖 AI requested data from ${serverName} (${toolName})...`);
592
+ try {
593
+ const response = await fetch(targetServer.url, {
594
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
595
+ body: JSON.stringify({ jsonrpc: "2.0", method: "tools/call", params: { name: toolName, arguments: targetArg ? { target: targetArg } : {} }, id: Date.now() })
596
+ });
597
+ if (response.ok) {
598
+ const responseData = await response.json();
599
+ const resultText = responseData.result?.content?.[0]?.text || "No data returned.";
600
+ fileActivityBuffer += `\n[${new Date().toLocaleTimeString()}] [MCP AI QUERY RESULT] Source: ${serverName} | Tool: ${toolName} | Target: ${targetArg || 'none'} \nResult:\n${resultText}\n`;
601
+ triggerDebouncedSync();
602
+ }
603
+ } catch (e) {
604
+ broadcastLocalLog('error', `âš ī¸ AI failed to query MCP server: ${serverName}`);
605
+ }
606
+ }
607
+ }
608
+
545
609
  if (msg.type === 'toast' || msg.type === 'response') {
546
610
  broadcastLocalLog('ai', `🔔 [AI]: ${msg.message || msg.text}`);
547
611
  }
@@ -561,66 +625,77 @@ function connectWebSocket() {
561
625
  }
562
626
 
563
627
  async function syncContext(projectPath) {
564
- if (!globalWs || globalWs.readyState !== WebSocket.OPEN) return;
628
+ if (!globalWs || globalWs.readyState !== WebSocket.OPEN) {
629
+ broadcastLocalLog('error', `âš ī¸ Cannot sync: Not connected to Cloud.`);
630
+ return;
631
+ }
632
+
633
+ broadcastLocalLog('system', `âŗ 15s silence reached. Preparing sync...`);
565
634
 
566
- const git = simpleGit(projectPath);
567
- const isRepo = await git.checkIsRepo().catch(() => false);
568
- if (!isRepo) return;
635
+ let diff = "";
636
+ let newFilesData = "";
637
+ let imagesData =[];
569
638
 
570
639
  try {
571
- const status = await git.status();
572
- const diff = await git.diff();
573
-
574
- if (!fileActivityBuffer.trim() && !diff) return;
575
-
576
- let newFilesData = "";
577
- let imagesData = [];
578
-
579
- const binaryExts =[
580
- '.png', '.jpg', '.jpeg', '.webp', '.gif', '.svg', '.ico',
581
- '.pdf', '.zip', '.tar', '.gz', '.mp4', '.mp3', '.wav',
582
- '.exe', '.dll', '.so', '.dylib',
583
- '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.bin'
584
- ];
585
-
586
- for (const file of status.not_added) {
587
- const ext = path.extname(file).toLowerCase();
588
- const fullPath = path.join(projectPath, file);
589
- if (!fs.existsSync(fullPath)) continue;
590
-
591
- const stat = fs.statSync(fullPath);
592
- if (stat.isDirectory() || stat.size > 5 * 1024 * 1024) continue;
593
-
594
- const fileData = fs.readFileSync(fullPath);
595
- const isBinary = binaryExts.includes(ext) || isBinaryData(fileData);
596
-
597
- if (isBinary) {
598
- const base64 = fileData.toString('base64');
599
- const mime = ['.png','.jpg','.jpeg','.webp','.gif'].includes(ext)
600
- ? `image/${ext.replace('.','')}`
601
- : 'application/octet-stream';
602
- imagesData.push(`data:${mime};base64,${base64}`);
603
- } else {
604
- const content = fileData.toString('utf8');
605
- newFilesData += `\n--- NEW FILE: ${file} (Scraped at ${new Date().toLocaleTimeString()}) ---\n${content.substring(0, 10000)}\n`;
640
+ const git = simpleGit(projectPath);
641
+ const isRepo = await git.checkIsRepo().catch(() => false);
642
+
643
+ if (isRepo) {
644
+ const status = await git.status();
645
+ diff = await git.diff();
646
+
647
+ const binaryExts =[
648
+ '.png', '.jpg', '.jpeg', '.webp', '.gif', '.svg', '.ico',
649
+ '.pdf', '.zip', '.tar', '.gz', '.mp4', '.mp3', '.wav',
650
+ '.exe', '.dll', '.so', '.dylib',
651
+ '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.bin'
652
+ ];
653
+
654
+ for (const file of status.not_added) {
655
+ const ext = path.extname(file).toLowerCase();
656
+ const fullPath = path.join(projectPath, file);
657
+ if (!fs.existsSync(fullPath)) continue;
658
+
659
+ const stat = fs.statSync(fullPath);
660
+ if (stat.isDirectory() || stat.size > 5 * 1024 * 1024) continue;
661
+
662
+ const fileData = fs.readFileSync(fullPath);
663
+ const isBinary = binaryExts.includes(ext) || isBinaryData(fileData);
664
+
665
+ if (isBinary) {
666
+ const base64 = fileData.toString('base64');
667
+ const mime = ['.png','.jpg','.jpeg','.webp','.gif'].includes(ext)
668
+ ? `image/${ext.replace('.','')}`
669
+ : 'application/octet-stream';
670
+ imagesData.push(`data:${mime};base64,${base64}`);
671
+ } else {
672
+ const content = fileData.toString('utf8');
673
+ newFilesData += `\n--- NEW FILE: ${file} (Scraped at ${new Date().toLocaleTimeString()}) ---\n${content.substring(0, 10000)}\n`;
674
+ }
606
675
  }
676
+ } else {
677
+ broadcastLocalLog('system', `â„šī¸ Folder is not a Git repo. Syncing MCP/Activity logs only.`);
607
678
  }
679
+ } catch (e) {
680
+ console.error("Git/Sync processing error:", e);
681
+ }
608
682
 
609
- globalWs.send(JSON.stringify({
610
- type: "context_sync",
611
- projectId: getActiveProject().id,
612
- data: {
613
- buffer: fileActivityBuffer,
614
- diffs: diff,
615
- new_files: newFilesData,
616
- images: imagesData
617
- }
618
- }));
683
+ if (!fileActivityBuffer.trim() && !diff && !newFilesData) {
684
+ broadcastLocalLog('system', `â„šī¸ Buffer empty. Nothing to sync.`);
685
+ return;
686
+ }
619
687
 
620
- broadcastLocalLog('sync', `✅ Context Batch synced to AI.`);
621
- fileActivityBuffer = "";
688
+ globalWs.send(JSON.stringify({
689
+ type: "context_sync",
690
+ projectId: getActiveProject().id,
691
+ data: {
692
+ buffer: fileActivityBuffer,
693
+ diffs: diff,
694
+ new_files: newFilesData,
695
+ images: imagesData
696
+ }
697
+ }));
622
698
 
623
- } catch (e) {
624
- console.error("Sync error:", e);
625
- }
699
+ broadcastLocalLog('sync', `✅ Context Batch synced to AI.`);
700
+ fileActivityBuffer = "";
626
701
  }