create-walle 0.8.0 → 0.9.0
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/package.json +1 -1
- package/template/README.md +123 -43
- package/template/claude-task-manager/db.js +2 -2
- package/template/claude-task-manager/public/index.html +42 -7
- package/template/claude-task-manager/public/js/walle.js +188 -10
- package/template/package.json +1 -1
- package/template/wall-e/api-walle.js +116 -0
- package/template/wall-e/chat.js +23 -3
- package/template/wall-e/core-tasks.js +1 -1
- package/template/wall-e/skills/_bundled/mcp-scan/SKILL.md +14 -0
- package/template/wall-e/skills/_bundled/mcp-scan/run.js +86 -0
- package/template/wall-e/skills/mcp-client.js +241 -13
- package/template/wall-e/tools/local-tools.js +65 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-walle",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Wall-E — your personal digital twin. AI agent that learns from Slack, email & calendar. Includes dashboard, chat, and 7 bundled skills.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"create-walle": "bin/create-walle.js"
|
package/template/README.md
CHANGED
|
@@ -1,34 +1,54 @@
|
|
|
1
|
-
# Wall-E
|
|
1
|
+
# Wall-E + CTM
|
|
2
2
|
|
|
3
|
-
**
|
|
3
|
+
**Wall-E** is your personal digital twin — an AI agent that learns from your Slack, email, and calendar to build a searchable second brain, answer questions in your voice, and automate daily workflows.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
**CTM** (Claude Task Manager) is an agent task manager and terminal multiplexer — a web dashboard for running Claude Code sessions, managing prompts, reviewing code, and controlling Wall-E.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Together they give you a local-first AI command center: a browser-based workspace where you manage Claude Code sessions, chat with an AI that knows your work context, and automate the repetitive parts of your day.
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
- **
|
|
13
|
-
- **
|
|
9
|
+
## What You Get
|
|
10
|
+
|
|
11
|
+
### CTM — Agent Task Manager
|
|
12
|
+
- **Terminal multiplexer** — run multiple Claude Code sessions side-by-side in browser tabs, with session history, search, and replay
|
|
13
|
+
- **Prompt library** — rich-text editor with versioning, folders, tags, and one-click send to any active session
|
|
14
|
+
- **Prompt queue** — batch multiple prompts and send them sequentially to a session with idle detection
|
|
15
|
+
- **Shadow Approver** — learns your permission patterns and auto-approves safe operations across sessions
|
|
16
|
+
- **Code review** — AI-assisted diff review across projects with inline comments
|
|
17
|
+
- **Session insights** — AI analysis of your Claude Code usage with actionable recommendations
|
|
18
|
+
- **Permission manager** — fine-grained control over what Claude Code can do automatically, with risk tiers and rule consolidation
|
|
19
|
+
|
|
20
|
+
### Wall-E — Personal AI Agent
|
|
21
|
+
- **Second brain** — ingests Slack messages, calendar events, and sent emails into a local SQLite database with full-text search
|
|
22
|
+
- **Chat** — ask questions about your work, meetings, and conversations — grounded in your actual data with citations
|
|
23
|
+
- **Scheduled skills** — morning briefings, Slack sync, calendar sync, email digest, and custom skills on configurable schedules
|
|
24
|
+
- **Background tasks** — recurring or one-shot tasks with live logs, skill execution, and checkpoint/resume
|
|
25
|
+
- **Knowledge graph** — automatically extracts facts, relationships, and patterns from your data over time
|
|
26
|
+
- **People intelligence** — tracks relationships, communication patterns, trust levels, and personas
|
|
27
|
+
- **MCP integration** — calls tools on Slack, GitHub, and custom MCP servers directly from chat
|
|
28
|
+
- **macOS native** — calendar via EventKit, email via AppleScript, Spotlight file search, desktop notifications, clipboard access
|
|
14
29
|
|
|
15
30
|
## Architecture
|
|
16
31
|
|
|
17
32
|
```
|
|
18
|
-
CTM (port 3456) Wall-E
|
|
33
|
+
CTM (port 3456) Wall-E Daemon
|
|
19
34
|
+-----------------------+ +------------------------+
|
|
20
35
|
| Web Dashboard | | Agent Daemon |
|
|
21
36
|
| - Sessions (pty) | <---> | - Ingest loop (60s) |
|
|
22
37
|
| - Prompts editor | | - Think loop (120s) |
|
|
23
|
-
| -
|
|
24
|
-
| -
|
|
25
|
-
| -
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
38
|
+
| - Prompt queue | | - Reflect loop (1hr) |
|
|
39
|
+
| - Shadow Approver | | - Skills loop (5min) |
|
|
40
|
+
| - Code review | | - Tasks loop (30s) |
|
|
41
|
+
| - Session insights | | |
|
|
42
|
+
| - Permission manager | | Chat Engine |
|
|
43
|
+
| - Wall-E chat tab | | - Tool use (local+MCP)|
|
|
44
|
+
+-----------------------+ | - Memory search |
|
|
45
|
+
| | - Skill execution |
|
|
46
|
+
v +------------------------+
|
|
47
|
+
task-manager.db |
|
|
48
|
+
(sessions, prompts, v
|
|
49
|
+
rules, reviews) wall-e-brain.db
|
|
50
|
+
(memories, knowledge,
|
|
51
|
+
people, skills, tasks)
|
|
32
52
|
```
|
|
33
53
|
|
|
34
54
|
## Quickstart
|
|
@@ -39,7 +59,7 @@ cd my-agent
|
|
|
39
59
|
node claude-task-manager/server.js
|
|
40
60
|
```
|
|
41
61
|
|
|
42
|
-
Open **http://localhost:3456** — the
|
|
62
|
+
Open **http://localhost:3456** — the setup page walks you through adding your API key and connecting integrations. Your name and timezone are auto-detected.
|
|
43
63
|
|
|
44
64
|
Or clone manually:
|
|
45
65
|
|
|
@@ -52,6 +72,66 @@ node claude-task-manager/server.js
|
|
|
52
72
|
|
|
53
73
|
The first run auto-creates `.env`, `wall-e-config.json`, and `~/.walle/data/`. Finish setup at http://localhost:3456/setup.html.
|
|
54
74
|
|
|
75
|
+
## Dashboard Features
|
|
76
|
+
|
|
77
|
+
### Sessions
|
|
78
|
+
Terminal multiplexer for Claude Code — create, monitor, and manage multiple sessions from a single browser tab. Features include:
|
|
79
|
+
- Live terminal output via WebSocket (xterm.js)
|
|
80
|
+
- Session search and AI-powered search across all session history
|
|
81
|
+
- AI auto-titling for sessions
|
|
82
|
+
- Project grouping and filtering
|
|
83
|
+
- Copy session command (with working directory)
|
|
84
|
+
- Session replay and review
|
|
85
|
+
|
|
86
|
+
### Prompts
|
|
87
|
+
Rich-text prompt editor with a full formatting toolbar. Organize prompts into folders, tag them, track usage across sessions, and send to any active Claude Code session with one click.
|
|
88
|
+
- **Composite prompts** — parent prompt with sub-prompts that compose into a single message
|
|
89
|
+
- **Prompt queue** — batch multiple prompts, send sequentially with idle detection between items
|
|
90
|
+
- **Power tools** — chains (multi-step sequences), templates, pattern detection, harvest (extract prompts from session history), and AI copilot suggestions
|
|
91
|
+
- **Usage tracking** — see which sessions used which prompts and how often
|
|
92
|
+
|
|
93
|
+
### Wall-E Chat
|
|
94
|
+
Chat directly with Wall-E from the dashboard. Wall-E has access to your full brain (memories, knowledge, people) and can:
|
|
95
|
+
- Search your Slack messages, emails, and calendar
|
|
96
|
+
- Run skills on demand (morning briefing, Slack sync, etc.)
|
|
97
|
+
- Call MCP tools (Slack, GitHub, etc.)
|
|
98
|
+
- Create and manage background tasks
|
|
99
|
+
- Execute shell commands, read files, take screenshots
|
|
100
|
+
- Answer questions grounded in your actual data
|
|
101
|
+
|
|
102
|
+
Sub-tabs: Chat, Tasks (background task manager), Skills (view and run), Brain (knowledge graph), Actions, Timeline, Questions, Status.
|
|
103
|
+
|
|
104
|
+
### Shadow Approver
|
|
105
|
+
Learns your permission patterns from Claude Code sessions and auto-approves safe operations. Features:
|
|
106
|
+
- Pattern learning from your approval history
|
|
107
|
+
- AI-driven approval decisions with confidence scoring
|
|
108
|
+
- Domain-specific trust tiers (Observe, Draft, Guarded, Autonomous)
|
|
109
|
+
- Rule consolidation (merges similar Bash rules)
|
|
110
|
+
- Decision history and audit trail
|
|
111
|
+
|
|
112
|
+
### Code Review
|
|
113
|
+
AI-assisted code review across projects:
|
|
114
|
+
- Inline diff viewer
|
|
115
|
+
- Multi-project support
|
|
116
|
+
- AI review comments with confidence levels
|
|
117
|
+
- Send review to a Claude Code session for implementation
|
|
118
|
+
|
|
119
|
+
### Session Insights
|
|
120
|
+
AI analysis of your Claude Code usage patterns:
|
|
121
|
+
- Workflow recommendations (automate repetitive tasks)
|
|
122
|
+
- Prompt effectiveness analysis
|
|
123
|
+
- Skill gap detection
|
|
124
|
+
- Cost-saving suggestions
|
|
125
|
+
- Session statistics
|
|
126
|
+
|
|
127
|
+
### Permission Manager
|
|
128
|
+
Fine-grained control over what Claude Code can do automatically:
|
|
129
|
+
- Search and filter across all rules
|
|
130
|
+
- Risk-based categorization (LOW, MEDIUM, HIGH)
|
|
131
|
+
- Per-project or global scope
|
|
132
|
+
- Rule consolidation for similar patterns
|
|
133
|
+
- Import/export with Claude Code's settings files
|
|
134
|
+
|
|
55
135
|
## Configuration
|
|
56
136
|
|
|
57
137
|
All configuration lives in `.env` (auto-generated on first run). Edit directly or use the browser setup page.
|
|
@@ -84,6 +164,7 @@ If you use `devbox ai -c claude`, Wall-E auto-reads your gateway credentials fro
|
|
|
84
164
|
| `ANTHROPIC_AUTH_TOKEN` | No | Auth token when using a gateway |
|
|
85
165
|
| `ANTHROPIC_CUSTOM_HEADERS_B64` | No | Base64-encoded custom headers (Portkey virtual keys, metadata) |
|
|
86
166
|
| `WALLE_OWNER_NAME` | Auto | Your name (auto-detected from `git config`) |
|
|
167
|
+
| `WALLE_MODEL` | Auto | Model selection (probed on setup: claude-sonnet-4-6, haiku-4-5, etc.) |
|
|
87
168
|
| `CTM_PORT` | No | Dashboard port (default: `3456`) |
|
|
88
169
|
| `WALL_E_PORT` | No | Wall-E API port (default: `CTM_PORT + 1`) |
|
|
89
170
|
| `WALL_E_DATA_DIR` | No | Data directory (default: `~/.walle/data`) |
|
|
@@ -105,8 +186,9 @@ Wall-E ships with skills that run on a schedule to keep your brain up to date:
|
|
|
105
186
|
| `slack-backfill` | manual | Full Slack history backfill (2022-present) |
|
|
106
187
|
| `email-sync` | every 30m | Syncs sent emails from macOS Mail via JXA |
|
|
107
188
|
| `email-digest` | daily 7am | Summarizes recent email activity |
|
|
108
|
-
| `morning-briefing` | daily 7am | AI-generated daily briefing
|
|
189
|
+
| `morning-briefing` | daily 7am | AI-generated daily briefing: calendar, Slack activity, tasks, questions |
|
|
109
190
|
| `memory-search` | on-demand | Full-text search across all memories |
|
|
191
|
+
| `file-ingest` | manual | Read a folder of files into the brain with glob filtering |
|
|
110
192
|
|
|
111
193
|
### Creating Custom Skills
|
|
112
194
|
|
|
@@ -128,22 +210,15 @@ tags: [sync, data]
|
|
|
128
210
|
|
|
129
211
|
Place skills in `wall-e/skills/_bundled/your-skill/SKILL.md` or load from a custom directory.
|
|
130
212
|
|
|
131
|
-
##
|
|
132
|
-
|
|
133
|
-
### Sessions Tab
|
|
134
|
-
Terminal multiplexer for Claude Code — create, monitor, and manage multiple sessions from a single browser tab.
|
|
213
|
+
## Integrations
|
|
135
214
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
Chat directly with Wall-E — ask about your schedule, search your memories, run skills, and get AI-powered answers grounded in your actual data.
|
|
144
|
-
|
|
145
|
-
### Code Review Tab
|
|
146
|
-
Review code changes across projects with inline diffs and AI-assisted review.
|
|
215
|
+
| Integration | How it works | Setup |
|
|
216
|
+
|---|---|---|
|
|
217
|
+
| **Slack** | OAuth + MCP protocol for search, read, send | Click "Connect" in setup page |
|
|
218
|
+
| **Google Calendar** | macOS EventKit (reads all calendars: Google, iCloud, Outlook) | Automatic on macOS |
|
|
219
|
+
| **Email** | macOS Mail via JXA/AppleScript | Automatic on macOS |
|
|
220
|
+
| **GitHub** | MCP server (if configured in Claude Code) | Via MCP config |
|
|
221
|
+
| **Custom MCP servers** | Any MCP-compatible server | Add to MCP config |
|
|
147
222
|
|
|
148
223
|
## API
|
|
149
224
|
|
|
@@ -152,17 +227,19 @@ CTM runs on port 3456. Key endpoints:
|
|
|
152
227
|
| Method | Path | Description |
|
|
153
228
|
|---|---|---|
|
|
154
229
|
| GET | `/api/services/status` | CTM and Wall-E process status |
|
|
230
|
+
| POST | `/api/restart/ctm` | Restart CTM server |
|
|
155
231
|
| POST | `/api/restart/walle` | Restart Wall-E daemon |
|
|
156
232
|
| POST | `/api/start/walle` | Start Wall-E daemon |
|
|
157
233
|
| POST | `/api/stop/walle` | Stop Wall-E daemon |
|
|
158
|
-
| GET | `/api/wall-e/status` |
|
|
234
|
+
| GET | `/api/wall-e/status` | Brain stats, loop health, owner info |
|
|
159
235
|
| GET | `/api/wall-e/memories` | List memories (filterable by source, since, limit) |
|
|
160
236
|
| GET | `/api/wall-e/knowledge` | List knowledge entries |
|
|
161
237
|
| GET | `/api/wall-e/people` | List known people |
|
|
162
238
|
| GET | `/api/wall-e/timeline` | Memory timeline |
|
|
163
|
-
|
|
|
239
|
+
| GET | `/api/wall-e/tasks` | List background tasks |
|
|
240
|
+
| POST | `/api/wall-e/chat` | Chat with Wall-E (supports SSE streaming) |
|
|
164
241
|
| GET | `/api/prompts` | List prompts |
|
|
165
|
-
| GET | `/api/sessions` | List
|
|
242
|
+
| GET | `/api/recent-sessions` | List recent Claude Code sessions |
|
|
166
243
|
|
|
167
244
|
## Tech Stack
|
|
168
245
|
|
|
@@ -170,24 +247,27 @@ CTM runs on port 3456. Key endpoints:
|
|
|
170
247
|
- **Database**: SQLite via better-sqlite3 (WAL mode, FTS5 full-text search)
|
|
171
248
|
- **AI**: Claude API via Anthropic SDK (supports Portkey gateway)
|
|
172
249
|
- **Frontend**: Vanilla HTML/CSS/JS (no build step, no React)
|
|
250
|
+
- **Terminal**: xterm.js (frontend) + node-pty (backend)
|
|
173
251
|
- **macOS integration**: EventKit (calendar), JXA (email/AppleScript), Spotlight (file search)
|
|
174
|
-
- **Terminal**: node-pty for session management
|
|
175
252
|
|
|
176
253
|
## Development
|
|
177
254
|
|
|
178
255
|
```bash
|
|
256
|
+
# Start dev instance (doesn't affect primary on :3456)
|
|
257
|
+
bash bin/dev.sh
|
|
258
|
+
|
|
179
259
|
# Run Wall-E tests
|
|
180
260
|
cd wall-e && npm test
|
|
181
261
|
|
|
182
|
-
# Run a single test file
|
|
183
|
-
node --test tests/brain.test.js
|
|
184
|
-
|
|
185
262
|
# Run Slack backfill manually
|
|
186
263
|
node scripts/slack-backfill.js 2024-01 # single month
|
|
187
264
|
node scripts/slack-backfill.js incremental # new messages only
|
|
188
265
|
|
|
189
266
|
# Run calendar sync manually
|
|
190
267
|
node skills/_bundled/google-calendar/run.js
|
|
268
|
+
|
|
269
|
+
# Run morning briefing manually
|
|
270
|
+
node skills/_bundled/morning-briefing/run.js
|
|
191
271
|
```
|
|
192
272
|
|
|
193
273
|
## License
|
|
@@ -1115,9 +1115,9 @@ function listSessionConversations({ search, limit, offset, hostname, allDevices
|
|
|
1115
1115
|
params.push(hostname);
|
|
1116
1116
|
}
|
|
1117
1117
|
if (search) {
|
|
1118
|
-
sql += ' AND (title LIKE ? OR first_message LIKE ? OR project_path LIKE ?)';
|
|
1118
|
+
sql += ' AND (title LIKE ? OR first_message LIKE ? OR project_path LIKE ? OR messages LIKE ?)';
|
|
1119
1119
|
const q = `%${search}%`;
|
|
1120
|
-
params.push(q, q, q);
|
|
1120
|
+
params.push(q, q, q, q);
|
|
1121
1121
|
}
|
|
1122
1122
|
sql += ' ORDER BY imported_at DESC';
|
|
1123
1123
|
if (limit) { sql += ' LIMIT ?'; params.push(limit); }
|
|
@@ -2396,6 +2396,7 @@
|
|
|
2396
2396
|
<button class="walle-subnav-btn active" data-view="chat" onclick="WE.showView('chat')">Chat</button>
|
|
2397
2397
|
<button class="walle-subnav-btn" data-view="tasks" onclick="WE.showView('tasks')">Tasks</button>
|
|
2398
2398
|
<button class="walle-subnav-btn" data-view="skills" onclick="WE.showView('skills')">Skills</button>
|
|
2399
|
+
<button class="walle-subnav-btn" data-view="mcp" onclick="WE.showView('mcp')">MCP</button>
|
|
2399
2400
|
<div style="position:relative;display:inline-block;" id="we-more-wrap">
|
|
2400
2401
|
<button class="walle-subnav-btn" onclick="WE.toggleMoreTabs()" id="we-more-btn">More <span style="font-size:10px;">▾</span></button>
|
|
2401
2402
|
<div id="we-more-dropdown" style="display:none;position:absolute;top:100%;left:0;z-index:999;background:var(--bg-light);border:1px solid var(--border);border-radius:6px;padding:4px 0;min-width:120px;box-shadow:0 4px 12px rgba(0,0,0,0.3);">
|
|
@@ -4552,6 +4553,7 @@ cwdInput.addEventListener('focus', () => {
|
|
|
4552
4553
|
|
|
4553
4554
|
// --- Recent Sessions ---
|
|
4554
4555
|
let allRecentSessions = [];
|
|
4556
|
+
let _convSearchPending = false;
|
|
4555
4557
|
let currentFilter = 'all';
|
|
4556
4558
|
let aiSearchMode = false;
|
|
4557
4559
|
let aiSearchDebounce = null;
|
|
@@ -4780,13 +4782,36 @@ function renderFilteredSessions() {
|
|
|
4780
4782
|
const q = document.getElementById('recent-search').value.toLowerCase();
|
|
4781
4783
|
let sessions = getFilteredSessions();
|
|
4782
4784
|
if (q && !aiSearchMode) {
|
|
4783
|
-
|
|
4784
|
-
|
|
4785
|
-
|
|
4786
|
-
s.
|
|
4787
|
-
|
|
4788
|
-
|
|
4789
|
-
|
|
4785
|
+
// First filter local metadata
|
|
4786
|
+
const metaMatches = new Set();
|
|
4787
|
+
sessions = sessions.filter(s => {
|
|
4788
|
+
const match = (s.firstMessage || '').toLowerCase().includes(q) ||
|
|
4789
|
+
(s.aiTitle || '').toLowerCase().includes(q) ||
|
|
4790
|
+
s.project.toLowerCase().includes(q) ||
|
|
4791
|
+
s.sessionId.toLowerCase().includes(q) ||
|
|
4792
|
+
(s.gitBranch || '').toLowerCase().includes(q);
|
|
4793
|
+
if (match) metaMatches.add(s.sessionId);
|
|
4794
|
+
return match;
|
|
4795
|
+
});
|
|
4796
|
+
// Also search imported conversations in DB (async, will re-render when results arrive)
|
|
4797
|
+
if (sessions.length === 0 && q.length >= 3 && !_convSearchPending) {
|
|
4798
|
+
_convSearchPending = true;
|
|
4799
|
+
fetch('/api/conversations?search=' + encodeURIComponent(q) + '&limit=20&all_devices=1&token=' + (state.token || ''))
|
|
4800
|
+
.then(r => r.json())
|
|
4801
|
+
.then(data => {
|
|
4802
|
+
_convSearchPending = false;
|
|
4803
|
+
const convResults = (data.backups ? [] : (Array.isArray(data) ? data : []));
|
|
4804
|
+
if (convResults.length === 0) return;
|
|
4805
|
+
// Merge conversation matches into session list
|
|
4806
|
+
for (const c of convResults) {
|
|
4807
|
+
if (metaMatches.has(c.session_id)) continue;
|
|
4808
|
+
const existing = allRecentSessions.find(s => s.sessionId === c.session_id);
|
|
4809
|
+
if (existing && !sessions.includes(existing)) sessions.push(existing);
|
|
4810
|
+
}
|
|
4811
|
+
if (sessions.length > 0) renderRecentSessions(sessions);
|
|
4812
|
+
})
|
|
4813
|
+
.catch(() => { _convSearchPending = false; });
|
|
4814
|
+
}
|
|
4790
4815
|
}
|
|
4791
4816
|
|
|
4792
4817
|
// Sort: pinned first (in user-defined order), then by modifiedAt
|
|
@@ -8059,6 +8084,15 @@ function removeQpItem(idx) {
|
|
|
8059
8084
|
renderQpItems();
|
|
8060
8085
|
}
|
|
8061
8086
|
|
|
8087
|
+
function resendQpItem(idx) {
|
|
8088
|
+
if (idx >= 0 && idx < qpItems.length) {
|
|
8089
|
+
qpItems[idx].status = 'pending';
|
|
8090
|
+
saveQpDraft();
|
|
8091
|
+
renderQpItems();
|
|
8092
|
+
toast('Item reset to pending — will be sent next', { type: 'info' });
|
|
8093
|
+
}
|
|
8094
|
+
}
|
|
8095
|
+
|
|
8062
8096
|
function toggleQpMode() {
|
|
8063
8097
|
qpMode = qpMode === 'auto' ? 'manual' : 'auto';
|
|
8064
8098
|
updateQpModeBtn();
|
|
@@ -8382,6 +8416,7 @@ function renderQpItems() {
|
|
|
8382
8416
|
</div>
|
|
8383
8417
|
<div style="display:flex;align-items:center;gap:2px;padding-top:2px;flex-shrink:0;">
|
|
8384
8418
|
<span style="font-size:9px;color:var(--fg-dim);background:var(--bg-lighter);padding:1px 5px;border-radius:8px;">${item.type === 'prompt' ? '#' + item.promptId : 'inline'}</span>
|
|
8419
|
+
${isSent && !isEditing ? `<button onclick="resendQpItem(${idx})" style="background:none;border:none;color:var(--fg-dim);cursor:pointer;font-size:10px;padding:0 2px;line-height:1;" onmouseover="this.style.color='var(--accent)'" onmouseout="this.style.color='var(--fg-dim)'" title="Re-send this item">↻</button>` : ''}
|
|
8385
8420
|
${!isEditing && item.type === 'inline' ? `<button onclick="saveQpItemAsPrompt(${idx})" style="background:none;border:none;color:var(--fg-dim);cursor:pointer;font-size:10px;padding:0 2px;line-height:1;" onmouseover="this.style.color='var(--green)'" onmouseout="this.style.color='var(--fg-dim)'" title="Save as prompt">💾</button>` : ''}
|
|
8386
8421
|
${!isEditing ? `<button onclick="startQpEdit(${idx})" style="background:none;border:none;color:var(--fg-dim);cursor:pointer;font-size:11px;padding:0 2px;line-height:1;" onmouseover="this.style.color='var(--accent)'" onmouseout="this.style.color='var(--fg-dim)'" title="Edit">✎</button>` : ''}
|
|
8387
8422
|
<button onclick="removeQpItem(${idx})" style="background:none;border:none;color:var(--fg-dim);cursor:pointer;font-size:14px;padding:0 2px;line-height:1;" onmouseover="this.style.color='var(--red)'" onmouseout="this.style.color='var(--fg-dim)'">×</button>
|
|
@@ -26,10 +26,15 @@ function resolveWalleBase() {
|
|
|
26
26
|
// Probe on load
|
|
27
27
|
resolveWalleBase();
|
|
28
28
|
|
|
29
|
-
function api(path) {
|
|
29
|
+
function api(path, opts) {
|
|
30
30
|
var token = window._ctmState?.token || '';
|
|
31
31
|
var sep = path.includes('?') ? '&' : '?';
|
|
32
|
-
|
|
32
|
+
var url = WALLE_BASE + '/api/wall-e' + path + sep + 'token=' + token;
|
|
33
|
+
var fetchOpts = opts || {};
|
|
34
|
+
return fetch(url, fetchOpts).then(function(r) {
|
|
35
|
+
if (!r.ok) return r.json().then(function(d) { throw Object.assign(new Error(d.error || r.statusText), d); });
|
|
36
|
+
return r.json();
|
|
37
|
+
});
|
|
33
38
|
}
|
|
34
39
|
|
|
35
40
|
function apiPost(path, body) {
|
|
@@ -228,6 +233,7 @@ WE.showView = function(view) {
|
|
|
228
233
|
else if (view === 'actions') WE.renderActions();
|
|
229
234
|
else if (view === 'tasks') WE.renderTasks();
|
|
230
235
|
else if (view === 'skills') WE.renderSkills();
|
|
236
|
+
else if (view === 'mcp') WE.renderMcp();
|
|
231
237
|
else if (view === 'timeline') WE.renderTimeline();
|
|
232
238
|
else if (view === 'questions') WE.renderQuestions();
|
|
233
239
|
else if (view === 'status') WE.renderStatus();
|
|
@@ -459,6 +465,151 @@ WE._filterBrain = function() {
|
|
|
459
465
|
};
|
|
460
466
|
|
|
461
467
|
// ---- Timeline View ----
|
|
468
|
+
// ---- MCP Servers tab ----
|
|
469
|
+
WE.renderMcp = function() {
|
|
470
|
+
var body = document.getElementById('walle-body');
|
|
471
|
+
safeSetHtml(body, '<div style="padding:16px;color:var(--fg-dim)">Loading MCP servers...</div>');
|
|
472
|
+
api('/mcp/servers').then(function(data) {
|
|
473
|
+
var servers = data.data || [];
|
|
474
|
+
var meta = data.meta || {};
|
|
475
|
+
var html = '';
|
|
476
|
+
// Header
|
|
477
|
+
html += '<div style="padding:12px 16px;display:flex;align-items:center;gap:8px;border-bottom:1px solid var(--border,#333)">';
|
|
478
|
+
html += '<h3 style="margin:0;font-size:14px;flex:1">MCP Servers</h3>';
|
|
479
|
+
var connected = servers.filter(function(s) { return s.status === 'connected' || s.status === 'cached'; }).length;
|
|
480
|
+
html += '<span style="font-size:11px;color:var(--fg-dim)">' + connected + '/' + servers.length + ' connected</span>';
|
|
481
|
+
if (meta.last_scan) html += '<span style="font-size:10px;color:var(--fg-dim)">Last scan: ' + esc(new Date(meta.last_scan).toLocaleString()) + '</span>';
|
|
482
|
+
html += '<button class="walle-btn" onclick="WE._scanMcp()">Scan All</button>';
|
|
483
|
+
html += '</div>';
|
|
484
|
+
|
|
485
|
+
if (servers.length === 0) {
|
|
486
|
+
html += '<div style="padding:24px;text-align:center;color:var(--fg-dim)">No MCP servers found.<br><span style="font-size:11px">Configure MCP servers in Claude Code (Settings > MCP Servers) and they will appear here.</span></div>';
|
|
487
|
+
} else {
|
|
488
|
+
for (var i = 0; i < servers.length; i++) {
|
|
489
|
+
var s = servers[i];
|
|
490
|
+
// Status colors
|
|
491
|
+
var dotColor = '#666'; // disconnected
|
|
492
|
+
var statusLabel = 'disconnected';
|
|
493
|
+
var statusColor = '#666';
|
|
494
|
+
if (s.status === 'connected') { dotColor = '#5c940d'; statusLabel = 'connected'; statusColor = '#5c940d'; }
|
|
495
|
+
else if (s.status === 'cached') { dotColor = '#228be6'; statusLabel = 'cached'; statusColor = '#228be6'; }
|
|
496
|
+
else if (s.status === 'authenticated') { dotColor = '#fab005'; statusLabel = 'authenticated'; statusColor = '#fab005'; }
|
|
497
|
+
|
|
498
|
+
html += '<div style="border-bottom:1px solid var(--border,#333);padding:10px 16px;" id="mcp-server-' + esc(s.name) + '">';
|
|
499
|
+
// Row 1: name + status + actions
|
|
500
|
+
html += '<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">';
|
|
501
|
+
html += '<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:' + dotColor + ';flex-shrink:0"></span>';
|
|
502
|
+
html += '<strong style="font-size:13px;">' + esc(s.name) + '</strong>';
|
|
503
|
+
html += '<span style="font-size:10px;color:var(--fg-dim);background:var(--bg-lighter,#2a2a3e);padding:1px 6px;border-radius:8px;">' + esc(s.type) + '</span>';
|
|
504
|
+
html += '<span style="font-size:10px;color:' + statusColor + '">' + statusLabel + '</span>';
|
|
505
|
+
html += '<span style="flex:1"></span>';
|
|
506
|
+
// Tool count
|
|
507
|
+
html += '<span style="font-size:10px;color:var(--fg-dim)">' + s.tool_count + ' tools</span>';
|
|
508
|
+
// Connect/Test button
|
|
509
|
+
if (s.status === 'connected' || s.status === 'cached') {
|
|
510
|
+
html += '<button class="walle-btn" style="font-size:10px;padding:2px 8px;" onclick="WE._connectMcp(\'' + esc(s.name) + '\',this)">Refresh</button>';
|
|
511
|
+
} else {
|
|
512
|
+
html += '<button class="walle-btn primary" style="font-size:10px;padding:2px 8px;" onclick="WE._connectMcp(\'' + esc(s.name) + '\',this)">Connect</button>';
|
|
513
|
+
}
|
|
514
|
+
html += '</div>';
|
|
515
|
+
// Row 2: URL
|
|
516
|
+
if (s.url) html += '<div style="font-size:10px;color:var(--fg-dim);margin-left:16px;margin-bottom:4px;font-family:monospace;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">' + esc(String(s.url)) + '</div>';
|
|
517
|
+
// Row 3: tools (collapsible if many)
|
|
518
|
+
if (s.tools.length > 0) {
|
|
519
|
+
var showAll = s.tools.length <= 10;
|
|
520
|
+
html += '<div style="margin-left:16px;display:flex;flex-wrap:wrap;gap:4px;">';
|
|
521
|
+
var displayTools = showAll ? s.tools : s.tools.slice(0, 8);
|
|
522
|
+
for (var j = 0; j < displayTools.length; j++) {
|
|
523
|
+
var t = displayTools[j];
|
|
524
|
+
html += '<span title="' + esc(t.description || '') + '" style="font-size:10px;background:var(--bg-lighter,#2a2a3e);padding:2px 6px;border-radius:4px;cursor:default;border:1px solid transparent;" onmouseover="this.style.borderColor=\'var(--accent)\'" onmouseout="this.style.borderColor=\'transparent\'">' + esc(t.name) + '</span>';
|
|
525
|
+
}
|
|
526
|
+
if (!showAll) {
|
|
527
|
+
html += '<span style="font-size:10px;color:var(--fg-dim);padding:2px 6px;cursor:pointer;" onclick="this.parentElement.style.display=\'none\';this.parentElement.nextElementSibling.style.display=\'flex\'">+' + (s.tools.length - 8) + ' more</span>';
|
|
528
|
+
}
|
|
529
|
+
html += '</div>';
|
|
530
|
+
if (!showAll) {
|
|
531
|
+
// Hidden expanded list
|
|
532
|
+
html += '<div style="margin-left:16px;display:none;flex-wrap:wrap;gap:4px;">';
|
|
533
|
+
for (var k = 0; k < s.tools.length; k++) {
|
|
534
|
+
var t2 = s.tools[k];
|
|
535
|
+
html += '<span title="' + esc(t2.description || '') + '" style="font-size:10px;background:var(--bg-lighter,#2a2a3e);padding:2px 6px;border-radius:4px;cursor:default;border:1px solid transparent;" onmouseover="this.style.borderColor=\'var(--accent)\'" onmouseout="this.style.borderColor=\'transparent\'">' + esc(t2.name) + '</span>';
|
|
536
|
+
}
|
|
537
|
+
html += '</div>';
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
html += '</div>';
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
safeSetHtml(body, html);
|
|
544
|
+
}).catch(function(e) {
|
|
545
|
+
safeSetHtml(body, '<div style="padding:16px;color:var(--red)">Failed to load MCP servers: ' + esc(e.message) + '</div>');
|
|
546
|
+
});
|
|
547
|
+
};
|
|
548
|
+
|
|
549
|
+
WE._connectMcp = function(serverName, btnEl) {
|
|
550
|
+
if (btnEl) {
|
|
551
|
+
btnEl.disabled = true;
|
|
552
|
+
btnEl.textContent = 'Connecting...';
|
|
553
|
+
}
|
|
554
|
+
api('/mcp/connect/' + encodeURIComponent(serverName), { method: 'POST' }).then(function(data) {
|
|
555
|
+
if (typeof window.toast === 'function') window.toast(serverName + ' connected — ' + data.tools + ' tools', { type: 'success' });
|
|
556
|
+
setTimeout(function() { WE.renderMcp(); }, 300);
|
|
557
|
+
}).catch(function(e) {
|
|
558
|
+
var msg = e.message || '';
|
|
559
|
+
if (msg.includes('401') || msg.includes('Unauthorized') || msg.includes('auth')) {
|
|
560
|
+
// Show Authenticate button instead of dead-end error
|
|
561
|
+
if (btnEl) {
|
|
562
|
+
btnEl.textContent = 'Authenticate';
|
|
563
|
+
btnEl.style.color = '#fab005';
|
|
564
|
+
btnEl.disabled = false;
|
|
565
|
+
btnEl.onclick = function() { WE._authMcp(serverName, btnEl); };
|
|
566
|
+
}
|
|
567
|
+
if (typeof window.toast === 'function') window.toast(serverName + ' needs authentication — click Authenticate to log in.', { type: 'warning' });
|
|
568
|
+
} else {
|
|
569
|
+
if (btnEl) { btnEl.textContent = 'Retry'; btnEl.disabled = false; }
|
|
570
|
+
if (typeof window.toast === 'function') window.toast(serverName + ': ' + msg.slice(0, 100), { type: 'error' });
|
|
571
|
+
}
|
|
572
|
+
});
|
|
573
|
+
};
|
|
574
|
+
|
|
575
|
+
WE._authMcp = function(serverName, btnEl) {
|
|
576
|
+
if (btnEl) {
|
|
577
|
+
btnEl.disabled = true;
|
|
578
|
+
btnEl.textContent = 'Opening browser...';
|
|
579
|
+
}
|
|
580
|
+
api('/mcp/auth/' + encodeURIComponent(serverName), { method: 'POST' }).then(function() {
|
|
581
|
+
if (typeof window.toast === 'function') window.toast('Check your browser — log in to ' + serverName, { type: 'info' });
|
|
582
|
+
if (btnEl) { btnEl.textContent = 'Waiting for login...'; }
|
|
583
|
+
// Poll for connection success
|
|
584
|
+
var attempts = 0;
|
|
585
|
+
var poll = setInterval(function() {
|
|
586
|
+
attempts++;
|
|
587
|
+
api('/mcp/connect/' + encodeURIComponent(serverName), { method: 'POST' }).then(function(data) {
|
|
588
|
+
clearInterval(poll);
|
|
589
|
+
if (typeof window.toast === 'function') window.toast(serverName + ' connected — ' + data.tools + ' tools!', { type: 'success' });
|
|
590
|
+
setTimeout(function() { WE.renderMcp(); }, 300);
|
|
591
|
+
}).catch(function() {
|
|
592
|
+
if (attempts > 30) {
|
|
593
|
+
clearInterval(poll);
|
|
594
|
+
if (btnEl) { btnEl.textContent = 'Timed out'; btnEl.disabled = false; }
|
|
595
|
+
}
|
|
596
|
+
});
|
|
597
|
+
}, 3000);
|
|
598
|
+
}).catch(function(e) {
|
|
599
|
+
if (btnEl) { btnEl.textContent = 'Auth failed'; btnEl.disabled = false; }
|
|
600
|
+
if (typeof window.toast === 'function') window.toast('Auth failed: ' + (e.message || '').slice(0, 100), { type: 'error' });
|
|
601
|
+
});
|
|
602
|
+
};
|
|
603
|
+
|
|
604
|
+
WE._scanMcp = function() {
|
|
605
|
+
api('/mcp/scan', { method: 'POST' }).then(function() {
|
|
606
|
+
if (typeof window.toast === 'function') window.toast('Scanning all MCP servers...', { type: 'info' });
|
|
607
|
+
setTimeout(function() { WE.renderMcp(); }, 8000);
|
|
608
|
+
}).catch(function(e) {
|
|
609
|
+
if (typeof window.toast === 'function') window.toast('Scan failed: ' + e.message, { type: 'error' });
|
|
610
|
+
});
|
|
611
|
+
};
|
|
612
|
+
|
|
462
613
|
WE.renderTimeline = function() {
|
|
463
614
|
timelineOffset = 0;
|
|
464
615
|
api('/timeline?limit=100').then(function(data) {
|
|
@@ -793,6 +944,7 @@ function renderChatUI() {
|
|
|
793
944
|
html += '<div class="we-export-bar">';
|
|
794
945
|
html += '<span class="we-export-count">' + chatSelected.size + ' selected</span>';
|
|
795
946
|
html += '<button class="walle-btn" onclick="WE._exportAsText()">Copy as Text</button>';
|
|
947
|
+
html += '<button class="walle-btn" onclick="WE._copyAsImage()">Copy as Image</button>';
|
|
796
948
|
html += '<button class="walle-btn" onclick="WE._exportAsImage()">Save as Image</button>';
|
|
797
949
|
html += '<button class="walle-btn danger" onclick="WE._deleteSelected()">Delete</button>';
|
|
798
950
|
html += '<button class="we-chat-search-clear" onclick="WE._clearSelection()" title="Clear selection">×</button>';
|
|
@@ -1420,6 +1572,11 @@ WE._toggleTurn = function(turnIdx) {
|
|
|
1420
1572
|
copyBtn.textContent = 'Copy as Text';
|
|
1421
1573
|
copyBtn.onclick = function() { WE._exportAsText(); };
|
|
1422
1574
|
newBar.appendChild(copyBtn);
|
|
1575
|
+
var clipImgBtn = document.createElement('button');
|
|
1576
|
+
clipImgBtn.className = 'walle-btn';
|
|
1577
|
+
clipImgBtn.textContent = 'Copy as Image';
|
|
1578
|
+
clipImgBtn.onclick = function() { WE._copyAsImage(); };
|
|
1579
|
+
newBar.appendChild(clipImgBtn);
|
|
1423
1580
|
var imgBtn = document.createElement('button');
|
|
1424
1581
|
imgBtn.className = 'walle-btn';
|
|
1425
1582
|
imgBtn.textContent = 'Save as Image';
|
|
@@ -1492,11 +1649,29 @@ WE._exportAsText = function() {
|
|
|
1492
1649
|
});
|
|
1493
1650
|
};
|
|
1494
1651
|
|
|
1495
|
-
WE.
|
|
1652
|
+
WE._copyAsImage = function() {
|
|
1496
1653
|
var turns = _getSelectedTurns();
|
|
1497
1654
|
if (turns.length === 0) return;
|
|
1655
|
+
var container = WE._buildExportContainer(turns);
|
|
1656
|
+
document.body.appendChild(container);
|
|
1657
|
+
if (typeof html2canvas !== 'undefined') {
|
|
1658
|
+
html2canvas(container, { backgroundColor: '#1a1a2e', scale: 2 }).then(function(canvas) {
|
|
1659
|
+
document.body.removeChild(container);
|
|
1660
|
+
canvas.toBlob(function(blob) {
|
|
1661
|
+
navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]).then(function() {
|
|
1662
|
+
if (typeof window.toast === 'function') window.toast('Image copied to clipboard', { type: 'success' });
|
|
1663
|
+
}).catch(function(e) {
|
|
1664
|
+
if (typeof window.toast === 'function') window.toast('Copy failed: ' + e.message, { type: 'error' });
|
|
1665
|
+
});
|
|
1666
|
+
}, 'image/png');
|
|
1667
|
+
});
|
|
1668
|
+
} else {
|
|
1669
|
+
document.body.removeChild(container);
|
|
1670
|
+
if (typeof window.toast === 'function') window.toast('html2canvas not loaded', { type: 'error' });
|
|
1671
|
+
}
|
|
1672
|
+
};
|
|
1498
1673
|
|
|
1499
|
-
|
|
1674
|
+
WE._buildExportContainer = function(turns) {
|
|
1500
1675
|
var styles = [
|
|
1501
1676
|
'body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #1a1a2e; color: #e0e0e0; padding: 24px; max-width: 700px; margin: 0 auto; }',
|
|
1502
1677
|
'.turn { border-bottom: 1px solid rgba(255,255,255,0.08); padding: 12px 0; }',
|
|
@@ -1512,8 +1687,6 @@ WE._exportAsImage = function() {
|
|
|
1512
1687
|
'blockquote { border-left: 2px solid #3b82f6; padding-left: 10px; color: #9ca3af; margin: 4px 0; }',
|
|
1513
1688
|
'.header { font-size: 10px; color: #666; margin-bottom: 16px; }',
|
|
1514
1689
|
].join('\n');
|
|
1515
|
-
|
|
1516
|
-
// Build turn HTML fragments
|
|
1517
1690
|
var turnHtml = '';
|
|
1518
1691
|
turns.forEach(function(t) {
|
|
1519
1692
|
turnHtml += '<div class="turn">';
|
|
@@ -1527,8 +1700,6 @@ WE._exportAsImage = function() {
|
|
|
1527
1700
|
}
|
|
1528
1701
|
turnHtml += '</div>';
|
|
1529
1702
|
});
|
|
1530
|
-
|
|
1531
|
-
// Render into off-screen container for html2canvas
|
|
1532
1703
|
var container = document.createElement('div');
|
|
1533
1704
|
container.style.cssText = 'position:fixed;left:-9999px;top:0;width:700px;background:#1a1a2e;color:#e0e0e0;padding:24px;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;';
|
|
1534
1705
|
var headerEl = document.createElement('div');
|
|
@@ -1538,10 +1709,17 @@ WE._exportAsImage = function() {
|
|
|
1538
1709
|
var contentEl = document.createElement('div');
|
|
1539
1710
|
safeSetHtml(contentEl, turnHtml);
|
|
1540
1711
|
container.appendChild(contentEl);
|
|
1541
|
-
// Inject styles
|
|
1542
1712
|
var styleEl = document.createElement('style');
|
|
1543
1713
|
styleEl.textContent = styles;
|
|
1544
1714
|
container.appendChild(styleEl);
|
|
1715
|
+
container._styles = styles; // stash for fallback
|
|
1716
|
+
return container;
|
|
1717
|
+
};
|
|
1718
|
+
|
|
1719
|
+
WE._exportAsImage = function() {
|
|
1720
|
+
var turns = _getSelectedTurns();
|
|
1721
|
+
if (turns.length === 0) return;
|
|
1722
|
+
var container = WE._buildExportContainer(turns);
|
|
1545
1723
|
document.body.appendChild(container);
|
|
1546
1724
|
|
|
1547
1725
|
if (typeof html2canvas !== 'undefined') {
|
|
@@ -1563,7 +1741,7 @@ WE._exportAsImage = function() {
|
|
|
1563
1741
|
var w = window.open('', '_blank', 'width=750,height=600');
|
|
1564
1742
|
var doc = w.document;
|
|
1565
1743
|
var styleTag = doc.createElement('style');
|
|
1566
|
-
styleTag.textContent =
|
|
1744
|
+
styleTag.textContent = container._styles;
|
|
1567
1745
|
doc.head.appendChild(styleTag);
|
|
1568
1746
|
var header = doc.createElement('div');
|
|
1569
1747
|
header.className = 'header';
|
package/template/package.json
CHANGED
|
@@ -239,6 +239,122 @@ function handleWalleApi(req, res, url) {
|
|
|
239
239
|
return true;
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
+
// GET /api/wall-e/mcp/servers — list all MCP servers and their cached tools
|
|
243
|
+
if (p === '/api/wall-e/mcp/servers' && m === 'GET') {
|
|
244
|
+
try {
|
|
245
|
+
const mcpClient = require('./skills/mcp-client');
|
|
246
|
+
const configs = mcpClient.loadMcpConfigs();
|
|
247
|
+
|
|
248
|
+
// Read cached tools from brain DB
|
|
249
|
+
const db = getReadDb();
|
|
250
|
+
let toolsByServer = {};
|
|
251
|
+
let scanMeta = {};
|
|
252
|
+
if (db) {
|
|
253
|
+
try {
|
|
254
|
+
const tools = db.prepare('SELECT * FROM mcp_tools_cache ORDER BY server, tool_name').all();
|
|
255
|
+
for (const t of tools) {
|
|
256
|
+
if (!toolsByServer[t.server]) toolsByServer[t.server] = [];
|
|
257
|
+
toolsByServer[t.server].push({ name: t.tool_name, description: t.description, scanned_at: t.scanned_at });
|
|
258
|
+
}
|
|
259
|
+
} catch {} // table may not exist yet
|
|
260
|
+
try {
|
|
261
|
+
const meta = db.prepare('SELECT * FROM mcp_scan_meta').all();
|
|
262
|
+
for (const row of meta) scanMeta[row.key] = row.value;
|
|
263
|
+
} catch {}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Check which servers are actually connected (have active transport)
|
|
267
|
+
const activeConnections = mcpClient.getActiveConnections ? mcpClient.getActiveConnections() : new Map();
|
|
268
|
+
|
|
269
|
+
const servers = Object.entries(configs).map(([name, cfg]) => {
|
|
270
|
+
const hasToken = !!(cfg.oauth?.accessToken);
|
|
271
|
+
const isConnected = activeConnections.has(name);
|
|
272
|
+
const hasCachedTools = (toolsByServer[name] || []).length > 0;
|
|
273
|
+
// Status: connected (green), authenticated but not tested (yellow), needs auth (gray)
|
|
274
|
+
let status = 'disconnected';
|
|
275
|
+
if (isConnected) status = 'connected';
|
|
276
|
+
else if (hasCachedTools) status = 'cached'; // previously connected, tools cached
|
|
277
|
+
else if (hasToken) status = 'authenticated';
|
|
278
|
+
return {
|
|
279
|
+
name,
|
|
280
|
+
type: cfg.type || (cfg.command ? 'stdio' : 'http'),
|
|
281
|
+
url: cfg.url || cfg.command || '',
|
|
282
|
+
authenticated: hasToken,
|
|
283
|
+
status,
|
|
284
|
+
tools: toolsByServer[name] || [],
|
|
285
|
+
tool_count: (toolsByServer[name] || []).length,
|
|
286
|
+
};
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
jsonResponse(res, { data: servers, meta: scanMeta });
|
|
290
|
+
} catch (e) {
|
|
291
|
+
jsonResponse(res, { error: e.message }, 500);
|
|
292
|
+
}
|
|
293
|
+
return true;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// POST /api/wall-e/mcp/scan — trigger MCP scan now
|
|
297
|
+
if (p === '/api/wall-e/mcp/scan' && m === 'POST') {
|
|
298
|
+
try {
|
|
299
|
+
const { execFile } = require('child_process');
|
|
300
|
+
const scriptPath = require('path').resolve(__dirname, 'skills/_bundled/mcp-scan/run.js');
|
|
301
|
+
execFile('node', [scriptPath], { timeout: 60000 }, (err, stdout, stderr) => {
|
|
302
|
+
if (err) console.error('[mcp-scan] Error:', err.message);
|
|
303
|
+
else console.log('[mcp-scan]', stdout.trim());
|
|
304
|
+
});
|
|
305
|
+
jsonResponse(res, { ok: true, message: 'MCP scan started' });
|
|
306
|
+
} catch (e) {
|
|
307
|
+
jsonResponse(res, { error: e.message }, 500);
|
|
308
|
+
}
|
|
309
|
+
return true;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// POST /api/wall-e/mcp/connect/:server — test connection to a specific MCP server
|
|
313
|
+
const mcpConnectMatch = p.match(/^\/api\/wall-e\/mcp\/connect\/([^/]+)$/);
|
|
314
|
+
if (mcpConnectMatch && m === 'POST') {
|
|
315
|
+
const serverName = decodeURIComponent(mcpConnectMatch[1]);
|
|
316
|
+
const mcpClient = require('./skills/mcp-client');
|
|
317
|
+
mcpClient.getConnection(serverName).then(function(conn) {
|
|
318
|
+
return conn.listTools();
|
|
319
|
+
}).then(function(tools) {
|
|
320
|
+
// Cache tools in brain DB (need writable DB)
|
|
321
|
+
if (brain && ensureBrainInit()) {
|
|
322
|
+
try {
|
|
323
|
+
const db = brain.getDb();
|
|
324
|
+
db.exec("CREATE TABLE IF NOT EXISTS mcp_tools_cache (server TEXT NOT NULL, tool_name TEXT NOT NULL, description TEXT, input_schema TEXT, scanned_at TEXT DEFAULT (datetime('now')), PRIMARY KEY (server, tool_name))");
|
|
325
|
+
db.prepare('DELETE FROM mcp_tools_cache WHERE server = ?').run(serverName);
|
|
326
|
+
const ins = db.prepare('INSERT INTO mcp_tools_cache (server, tool_name, description, input_schema) VALUES (?, ?, ?, ?)');
|
|
327
|
+
for (const t of tools) ins.run(serverName, t.name, t.description || '', JSON.stringify(t.inputSchema || {}));
|
|
328
|
+
} catch (e) { console.error('[mcp-connect] Cache error:', e.message); }
|
|
329
|
+
}
|
|
330
|
+
jsonResponse(res, { ok: true, server: serverName, tools: tools.length, status: 'connected' });
|
|
331
|
+
}).catch(function(e) {
|
|
332
|
+
const msg = e.message || '';
|
|
333
|
+
const needsAuth = msg.includes('401') || msg.includes('Unauthorized') || msg.includes('auth');
|
|
334
|
+
jsonResponse(res, { error: msg.slice(0, 200), server: serverName, needs_auth: needsAuth }, needsAuth ? 401 : 500);
|
|
335
|
+
});
|
|
336
|
+
return true;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// POST /api/wall-e/mcp/auth/:server — start OAuth flow for an MCP server
|
|
340
|
+
const mcpAuthMatch = p.match(/^\/api\/wall-e\/mcp\/auth\/([^/]+)$/);
|
|
341
|
+
if (mcpAuthMatch && m === 'POST') {
|
|
342
|
+
const serverName = decodeURIComponent(mcpAuthMatch[1]);
|
|
343
|
+
try {
|
|
344
|
+
const mcpClient = require('./skills/mcp-client');
|
|
345
|
+
// Fire and forget — OAuth opens browser, callback handles the rest
|
|
346
|
+
mcpClient.authenticateMcpServer(serverName).then(function(token) {
|
|
347
|
+
console.log('[wall-e] MCP OAuth completed for ' + serverName);
|
|
348
|
+
}).catch(function(err) {
|
|
349
|
+
console.error('[wall-e] MCP OAuth failed for ' + serverName + ':', err.message);
|
|
350
|
+
});
|
|
351
|
+
jsonResponse(res, { ok: true, message: 'OAuth flow started — check your browser' });
|
|
352
|
+
} catch (e) {
|
|
353
|
+
jsonResponse(res, { error: e.message }, 500);
|
|
354
|
+
}
|
|
355
|
+
return true;
|
|
356
|
+
}
|
|
357
|
+
|
|
242
358
|
// GET /api/wall-e/status
|
|
243
359
|
if (p === '/api/wall-e/status' && m === 'GET') {
|
|
244
360
|
const result = getStatus();
|
package/template/wall-e/chat.js
CHANGED
|
@@ -48,14 +48,25 @@ async function chat(message, opts = {}) {
|
|
|
48
48
|
}
|
|
49
49
|
} catch {}
|
|
50
50
|
|
|
51
|
-
// Load available MCP servers so WALL-E knows what it can
|
|
51
|
+
// Load available MCP servers and their cached tools so WALL-E knows what it can do
|
|
52
52
|
let mcpServerList = '';
|
|
53
53
|
try {
|
|
54
54
|
const { loadMcpConfigs } = require('./skills/mcp-client');
|
|
55
55
|
const configs = loadMcpConfigs();
|
|
56
|
+
// Read cached tools from brain DB
|
|
57
|
+
let toolsByServer = {};
|
|
58
|
+
try {
|
|
59
|
+
const rows = brain.getDb().prepare('SELECT server, tool_name FROM mcp_tools_cache ORDER BY server').all();
|
|
60
|
+
for (const r of rows) {
|
|
61
|
+
if (!toolsByServer[r.server]) toolsByServer[r.server] = [];
|
|
62
|
+
toolsByServer[r.server].push(r.tool_name);
|
|
63
|
+
}
|
|
64
|
+
} catch {} // table may not exist yet
|
|
56
65
|
mcpServerList = Object.entries(configs).map(([name, cfg]) => {
|
|
57
66
|
const hasAuth = cfg.oauth?.accessToken ? 'authenticated' : 'needs auth';
|
|
58
|
-
|
|
67
|
+
const tools = toolsByServer[name];
|
|
68
|
+
const toolStr = tools ? ` — tools: ${tools.slice(0, 10).join(', ')}${tools.length > 10 ? ' +' + (tools.length - 10) + ' more' : ''}` : '';
|
|
69
|
+
return `- ${name}: ${cfg.type || 'stdio'} [${hasAuth}]${toolStr}`;
|
|
59
70
|
}).join('\n');
|
|
60
71
|
} catch {}
|
|
61
72
|
|
|
@@ -184,6 +195,7 @@ After gathering evidence, ALWAYS use the **think** tool before responding. This
|
|
|
184
195
|
- **search_memories**: Full-text search with BM25 ranking. Use source:"slack" for Slack only.
|
|
185
196
|
- remember_fact: Store new knowledge the user teaches you.
|
|
186
197
|
- run_skill, mcp_call, list_mcp_tools: For actions and external services.
|
|
198
|
+
- When mcp_call returns auth_required, tell the user which MCP server needs authentication and suggest they connect it via Claude Code or the MCP tab in the dashboard.
|
|
187
199
|
|
|
188
200
|
### Local Machine Tools (macOS)
|
|
189
201
|
- **web_fetch**: Fetch any URL — weather, news, APIs, documentation. Use for ALL real-time data requests.
|
|
@@ -484,7 +496,15 @@ When ${ownerName} asks you to DO something (create a file, set a reminder, searc
|
|
|
484
496
|
}
|
|
485
497
|
return result;
|
|
486
498
|
} catch (err) {
|
|
487
|
-
|
|
499
|
+
const msg = err.message || '';
|
|
500
|
+
if (msg.includes('401') || msg.includes('Unauthorized') || msg.includes('auth')) {
|
|
501
|
+
return {
|
|
502
|
+
error: `Authentication failed for ${input.server}. This MCP server requires OAuth tokens that are managed by Claude Code. Ask the user to authenticate ${input.server} through Claude Code first, then try again.`,
|
|
503
|
+
auth_required: true,
|
|
504
|
+
server: input.server,
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
return { error: msg };
|
|
488
508
|
}
|
|
489
509
|
}
|
|
490
510
|
if (name === 'list_mcp_tools') {
|
|
@@ -47,7 +47,7 @@ module.exports = [
|
|
|
47
47
|
description: 'One-time full sync of sent email history from macOS Mail. Run manually to backfill.',
|
|
48
48
|
type: 'once',
|
|
49
49
|
skill: 'email-sync',
|
|
50
|
-
skill_config: JSON.stringify({ days_back:
|
|
50
|
+
skill_config: JSON.stringify({ days_back: 3650, sync_inbox: true }),
|
|
51
51
|
priority: 'normal',
|
|
52
52
|
},
|
|
53
53
|
];
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: mcp-scan
|
|
3
|
+
description: Discover and cache tools from all Claude Code MCP servers
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
execution: script
|
|
6
|
+
entry: run.js
|
|
7
|
+
trigger:
|
|
8
|
+
type: interval
|
|
9
|
+
schedule: "every 1h"
|
|
10
|
+
tags: [mcp, discovery, tools]
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
Connects to all MCP servers configured in Claude Code and caches their available tools.
|
|
14
|
+
This lets Wall-E know what tools are available across all integrations (Glean, Slack, Rootly, Mezmo, etc.).
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* MCP Scan Skill — discovers tools from all Claude Code MCP servers
|
|
6
|
+
* and caches them in the brain DB for fast lookup.
|
|
7
|
+
*
|
|
8
|
+
* Runs hourly. Results are stored in the `mcp_tools_cache` table
|
|
9
|
+
* and used by the chat system prompt so Wall-E knows what it can do.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const brain = require(path.resolve(__dirname, '../../..', 'brain'));
|
|
14
|
+
const mcpClient = require(path.resolve(__dirname, '../..', 'mcp-client'));
|
|
15
|
+
|
|
16
|
+
async function main() {
|
|
17
|
+
brain.initDb();
|
|
18
|
+
const db = brain.getDb();
|
|
19
|
+
|
|
20
|
+
// Ensure cache table exists
|
|
21
|
+
db.exec(`
|
|
22
|
+
CREATE TABLE IF NOT EXISTS mcp_tools_cache (
|
|
23
|
+
server TEXT NOT NULL,
|
|
24
|
+
tool_name TEXT NOT NULL,
|
|
25
|
+
description TEXT,
|
|
26
|
+
input_schema TEXT,
|
|
27
|
+
scanned_at TEXT DEFAULT (datetime('now')),
|
|
28
|
+
PRIMARY KEY (server, tool_name)
|
|
29
|
+
)
|
|
30
|
+
`);
|
|
31
|
+
|
|
32
|
+
const configs = mcpClient.loadMcpConfigs();
|
|
33
|
+
const serverNames = Object.keys(configs);
|
|
34
|
+
console.log('[mcp-scan] Found ' + serverNames.length + ' MCP servers: ' + serverNames.join(', '));
|
|
35
|
+
|
|
36
|
+
let totalTools = 0;
|
|
37
|
+
let successServers = 0;
|
|
38
|
+
const failedServers = [];
|
|
39
|
+
|
|
40
|
+
for (const [name, config] of Object.entries(configs)) {
|
|
41
|
+
try {
|
|
42
|
+
const conn = await mcpClient.getConnection(name);
|
|
43
|
+
const tools = await conn.listTools();
|
|
44
|
+
|
|
45
|
+
// Clear old tools for this server and insert fresh
|
|
46
|
+
db.prepare('DELETE FROM mcp_tools_cache WHERE server = ?').run(name);
|
|
47
|
+
const insert = db.prepare(
|
|
48
|
+
'INSERT INTO mcp_tools_cache (server, tool_name, description, input_schema) VALUES (?, ?, ?, ?)'
|
|
49
|
+
);
|
|
50
|
+
for (const tool of tools) {
|
|
51
|
+
insert.run(name, tool.name, tool.description || '', JSON.stringify(tool.inputSchema || {}));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
totalTools += tools.length;
|
|
55
|
+
successServers++;
|
|
56
|
+
console.log(' ' + name + ': ' + tools.length + ' tools');
|
|
57
|
+
} catch (err) {
|
|
58
|
+
failedServers.push(name + ': ' + err.message.slice(0, 80));
|
|
59
|
+
console.log(' ' + name + ': FAILED - ' + err.message.slice(0, 80));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Update scan metadata
|
|
64
|
+
db.exec(`
|
|
65
|
+
CREATE TABLE IF NOT EXISTS mcp_scan_meta (
|
|
66
|
+
key TEXT PRIMARY KEY,
|
|
67
|
+
value TEXT
|
|
68
|
+
)
|
|
69
|
+
`);
|
|
70
|
+
const upsert = db.prepare('INSERT OR REPLACE INTO mcp_scan_meta (key, value) VALUES (?, ?)');
|
|
71
|
+
upsert.run('last_scan', new Date().toISOString());
|
|
72
|
+
upsert.run('server_count', String(serverNames.length));
|
|
73
|
+
upsert.run('tool_count', String(totalTools));
|
|
74
|
+
|
|
75
|
+
console.log('\n[mcp-scan] Done: ' + successServers + '/' + serverNames.length + ' servers, ' + totalTools + ' tools');
|
|
76
|
+
if (failedServers.length > 0) {
|
|
77
|
+
console.log('Failed: ' + failedServers.join(', '));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
brain.closeDb();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
main().catch(function(err) {
|
|
84
|
+
console.error('[mcp-scan] Error:', err.message);
|
|
85
|
+
process.exit(1);
|
|
86
|
+
});
|
|
@@ -23,6 +23,14 @@ function loadMcpConfigs() {
|
|
|
23
23
|
}
|
|
24
24
|
} catch {}
|
|
25
25
|
|
|
26
|
+
// From .claude.json (global MCP servers — Glean, Mezmo, etc.)
|
|
27
|
+
try {
|
|
28
|
+
const claudeJson = JSON.parse(fs.readFileSync(path.join(process.env.HOME, '.claude.json'), 'utf8'));
|
|
29
|
+
for (const [name, cfg] of Object.entries(claudeJson.mcpServers || {})) {
|
|
30
|
+
if (!configs[name]) configs[name] = { ...cfg, name };
|
|
31
|
+
}
|
|
32
|
+
} catch {}
|
|
33
|
+
|
|
26
34
|
// From mcp.json (stdio servers)
|
|
27
35
|
try {
|
|
28
36
|
const mcp = JSON.parse(fs.readFileSync(path.join(CLAUDE_DIR, 'mcp.json'), 'utf8'));
|
|
@@ -201,8 +209,8 @@ class HttpTransport {
|
|
|
201
209
|
this.nextId = 1;
|
|
202
210
|
this.initialized = false;
|
|
203
211
|
this.sessionUrl = null;
|
|
204
|
-
//
|
|
205
|
-
this.authHeaders = {};
|
|
212
|
+
// Start with static headers from config (e.g. Mezmo, Rootly bearer tokens)
|
|
213
|
+
this.authHeaders = config.headers ? { ...config.headers } : {};
|
|
206
214
|
}
|
|
207
215
|
|
|
208
216
|
async connect() {
|
|
@@ -210,18 +218,77 @@ class HttpTransport {
|
|
|
210
218
|
if (this.config.oauth) {
|
|
211
219
|
this._loadOAuthToken();
|
|
212
220
|
}
|
|
221
|
+
// Also try loading a previously saved token from our own storage
|
|
222
|
+
if (!this.authHeaders['Authorization']) {
|
|
223
|
+
this._loadSavedToken();
|
|
224
|
+
}
|
|
213
225
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
226
|
+
try {
|
|
227
|
+
// Initialize
|
|
228
|
+
const result = await this._send('initialize', {
|
|
229
|
+
protocolVersion: '2024-11-05',
|
|
230
|
+
capabilities: {},
|
|
231
|
+
clientInfo: { name: 'wall-e', version: '0.1.0' },
|
|
232
|
+
});
|
|
220
233
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
234
|
+
// Send initialized notification
|
|
235
|
+
await this._sendNotification('notifications/initialized');
|
|
236
|
+
this.initialized = true;
|
|
237
|
+
return result;
|
|
238
|
+
} catch (err) {
|
|
239
|
+
// If 401, try to discover OAuth and re-throw with auth info
|
|
240
|
+
if (err.message && err.message.includes('401')) {
|
|
241
|
+
const oauthMeta = await this._discoverOAuth();
|
|
242
|
+
if (oauthMeta) {
|
|
243
|
+
err.oauthDiscovered = true;
|
|
244
|
+
err.oauthMeta = oauthMeta;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
throw err;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
_loadSavedToken() {
|
|
252
|
+
try {
|
|
253
|
+
// Check slack-mcp.js token for Slack servers (setup page uses this path)
|
|
254
|
+
if (this.config.url && this.config.url.includes('mcp.slack.com')) {
|
|
255
|
+
const slackTokenPath = path.join(CLAUDE_DIR, 'wall-e-slack-token.json');
|
|
256
|
+
if (fs.existsSync(slackTokenPath)) {
|
|
257
|
+
const data = JSON.parse(fs.readFileSync(slackTokenPath, 'utf8'));
|
|
258
|
+
if (data.access_token) {
|
|
259
|
+
this.authHeaders['Authorization'] = 'Bearer ' + data.access_token;
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
const tokenPath = path.join(CLAUDE_DIR, 'wall-e-mcp-tokens', this.config.name + '.json');
|
|
265
|
+
if (fs.existsSync(tokenPath)) {
|
|
266
|
+
const data = JSON.parse(fs.readFileSync(tokenPath, 'utf8'));
|
|
267
|
+
if (data.access_token && (!data.expires_at || data.expires_at > Date.now())) {
|
|
268
|
+
this.authHeaders['Authorization'] = 'Bearer ' + data.access_token;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
} catch {}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async _discoverOAuth() {
|
|
275
|
+
try {
|
|
276
|
+
// Standard MCP OAuth discovery: /.well-known/oauth-authorization-server at the server's origin
|
|
277
|
+
const urlObj = new URL(this.url);
|
|
278
|
+
const discoveryUrl = urlObj.origin + '/.well-known/oauth-authorization-server';
|
|
279
|
+
const res = await fetch(discoveryUrl, { signal: AbortSignal.timeout(5000) });
|
|
280
|
+
if (res.ok) {
|
|
281
|
+
const meta = await res.json();
|
|
282
|
+
return {
|
|
283
|
+
authorization_endpoint: meta.authorization_endpoint,
|
|
284
|
+
token_endpoint: meta.token_endpoint,
|
|
285
|
+
registration_endpoint: meta.registration_endpoint,
|
|
286
|
+
scopes_supported: meta.scopes_supported,
|
|
287
|
+
issuer: meta.issuer,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
} catch {}
|
|
291
|
+
return null;
|
|
225
292
|
}
|
|
226
293
|
|
|
227
294
|
_loadOAuthToken() {
|
|
@@ -343,7 +410,7 @@ async function getConnection(serverName) {
|
|
|
343
410
|
if (!config) throw new Error(`MCP server "${serverName}" not found in config`);
|
|
344
411
|
|
|
345
412
|
let transport;
|
|
346
|
-
if (config.type === 'http' && config.url) {
|
|
413
|
+
if ((config.type === 'http' || config.type === 'sse') && config.url) {
|
|
347
414
|
transport = new HttpTransport(config);
|
|
348
415
|
} else if (config.command) {
|
|
349
416
|
transport = new StdioTransport(config);
|
|
@@ -396,11 +463,172 @@ function disconnectAll() {
|
|
|
396
463
|
connections.clear();
|
|
397
464
|
}
|
|
398
465
|
|
|
466
|
+
function getActiveConnections() {
|
|
467
|
+
return connections;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Run OAuth flow for an MCP server: discover metadata, register client,
|
|
472
|
+
* open browser for auth, handle callback, save token.
|
|
473
|
+
*/
|
|
474
|
+
async function authenticateMcpServer(serverName) {
|
|
475
|
+
const configs = loadMcpConfigs();
|
|
476
|
+
const config = configs[serverName];
|
|
477
|
+
if (!config) throw new Error('MCP server "' + serverName + '" not found');
|
|
478
|
+
|
|
479
|
+
// Slack uses its own OAuth flow — delegate to slack-mcp.js
|
|
480
|
+
if (config.url && config.url.includes('mcp.slack.com')) {
|
|
481
|
+
const slackMcp = require('../tools/slack-mcp');
|
|
482
|
+
return slackMcp.authenticate();
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Discover OAuth metadata
|
|
486
|
+
const urlObj = new URL(config.url);
|
|
487
|
+
const discoveryUrl = urlObj.origin + '/.well-known/oauth-authorization-server';
|
|
488
|
+
const discRes = await fetch(discoveryUrl, { signal: AbortSignal.timeout(5000) });
|
|
489
|
+
if (!discRes.ok) throw new Error('No OAuth discovery at ' + discoveryUrl);
|
|
490
|
+
const meta = await discRes.json();
|
|
491
|
+
|
|
492
|
+
if (!meta.authorization_endpoint || !meta.token_endpoint) {
|
|
493
|
+
throw new Error('OAuth metadata missing endpoints');
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Dynamic client registration (if supported)
|
|
497
|
+
let clientId, clientSecret;
|
|
498
|
+
if (meta.registration_endpoint) {
|
|
499
|
+
const regRes = await fetch(meta.registration_endpoint, {
|
|
500
|
+
method: 'POST',
|
|
501
|
+
headers: { 'Content-Type': 'application/json' },
|
|
502
|
+
body: JSON.stringify({
|
|
503
|
+
client_name: 'Wall-E AI Agent',
|
|
504
|
+
redirect_uris: ['http://localhost:3119/callback'],
|
|
505
|
+
grant_types: ['authorization_code'],
|
|
506
|
+
response_types: ['code'],
|
|
507
|
+
token_endpoint_auth_method: 'none',
|
|
508
|
+
}),
|
|
509
|
+
signal: AbortSignal.timeout(10000),
|
|
510
|
+
});
|
|
511
|
+
if (regRes.ok) {
|
|
512
|
+
const reg = await regRes.json();
|
|
513
|
+
clientId = reg.client_id;
|
|
514
|
+
clientSecret = reg.client_secret;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
// Fallback: use configured clientId if dynamic registration fails
|
|
518
|
+
if (!clientId && config.oauth?.clientId) {
|
|
519
|
+
clientId = config.oauth.clientId;
|
|
520
|
+
}
|
|
521
|
+
if (!clientId) throw new Error('No client_id available (dynamic registration failed and none configured)');
|
|
522
|
+
|
|
523
|
+
// PKCE challenge
|
|
524
|
+
const crypto = require('crypto');
|
|
525
|
+
const codeVerifier = crypto.randomBytes(32).toString('base64url');
|
|
526
|
+
const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url');
|
|
527
|
+
const state = crypto.randomBytes(16).toString('hex');
|
|
528
|
+
|
|
529
|
+
// Determine scopes
|
|
530
|
+
const scopes = (meta.scopes_supported || []).filter(function(s) {
|
|
531
|
+
return ['mcp', 'openid', 'search', 'people', 'chat', 'offline_access'].includes(s);
|
|
532
|
+
}).join(' ') || 'mcp';
|
|
533
|
+
|
|
534
|
+
const CALLBACK_PORT = 3119;
|
|
535
|
+
|
|
536
|
+
return new Promise(function(resolve, reject) {
|
|
537
|
+
const http = require('http');
|
|
538
|
+
const timeout = setTimeout(function() {
|
|
539
|
+
server.close();
|
|
540
|
+
reject(new Error('OAuth timeout (2 minutes)'));
|
|
541
|
+
}, 120000);
|
|
542
|
+
|
|
543
|
+
const server = http.createServer(async function(req, res) {
|
|
544
|
+
const reqUrl = new URL(req.url, 'http://localhost:' + CALLBACK_PORT);
|
|
545
|
+
if (reqUrl.pathname !== '/callback') { res.writeHead(404); res.end(); return; }
|
|
546
|
+
|
|
547
|
+
const code = reqUrl.searchParams.get('code');
|
|
548
|
+
const retState = reqUrl.searchParams.get('state');
|
|
549
|
+
if (retState !== state) { res.writeHead(400); res.end('Invalid state'); clearTimeout(timeout); server.close(); reject(new Error('OAuth state mismatch')); return; }
|
|
550
|
+
|
|
551
|
+
try {
|
|
552
|
+
// Exchange code for token
|
|
553
|
+
const tokenBody = new URLSearchParams({
|
|
554
|
+
grant_type: 'authorization_code',
|
|
555
|
+
code: code,
|
|
556
|
+
redirect_uri: 'http://localhost:' + CALLBACK_PORT + '/callback',
|
|
557
|
+
client_id: clientId,
|
|
558
|
+
code_verifier: codeVerifier,
|
|
559
|
+
});
|
|
560
|
+
if (clientSecret) tokenBody.set('client_secret', clientSecret);
|
|
561
|
+
|
|
562
|
+
const tokenRes = await fetch(meta.token_endpoint, {
|
|
563
|
+
method: 'POST',
|
|
564
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
565
|
+
body: tokenBody.toString(),
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
if (!tokenRes.ok) {
|
|
569
|
+
const errText = await tokenRes.text();
|
|
570
|
+
throw new Error('Token exchange failed: ' + errText.slice(0, 200));
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
const tokenData = await tokenRes.json();
|
|
574
|
+
|
|
575
|
+
// Save token
|
|
576
|
+
const tokenDir = path.join(CLAUDE_DIR, 'wall-e-mcp-tokens');
|
|
577
|
+
if (!fs.existsSync(tokenDir)) fs.mkdirSync(tokenDir, { recursive: true });
|
|
578
|
+
fs.writeFileSync(path.join(tokenDir, serverName + '.json'), JSON.stringify({
|
|
579
|
+
access_token: tokenData.access_token,
|
|
580
|
+
refresh_token: tokenData.refresh_token,
|
|
581
|
+
expires_at: tokenData.expires_in ? Date.now() + tokenData.expires_in * 1000 : null,
|
|
582
|
+
obtained_at: new Date().toISOString(),
|
|
583
|
+
server: serverName,
|
|
584
|
+
}, null, 2));
|
|
585
|
+
|
|
586
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
587
|
+
res.end('<html><body style="font-family:system-ui;text-align:center;padding:40px"><h2>Wall-E connected to ' + serverName + '!</h2><p>You can close this tab.</p></body></html>');
|
|
588
|
+
|
|
589
|
+
clearTimeout(timeout);
|
|
590
|
+
server.close();
|
|
591
|
+
resolve(tokenData.access_token);
|
|
592
|
+
} catch (err) {
|
|
593
|
+
res.writeHead(500);
|
|
594
|
+
res.end('Token exchange failed: ' + err.message);
|
|
595
|
+
clearTimeout(timeout);
|
|
596
|
+
server.close();
|
|
597
|
+
reject(err);
|
|
598
|
+
}
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
server.listen(CALLBACK_PORT, function() {
|
|
602
|
+
const authUrl = meta.authorization_endpoint
|
|
603
|
+
+ '?response_type=code'
|
|
604
|
+
+ '&client_id=' + encodeURIComponent(clientId)
|
|
605
|
+
+ '&redirect_uri=' + encodeURIComponent('http://localhost:' + CALLBACK_PORT + '/callback')
|
|
606
|
+
+ '&scope=' + encodeURIComponent(scopes)
|
|
607
|
+
+ '&state=' + state
|
|
608
|
+
+ '&code_challenge=' + codeChallenge
|
|
609
|
+
+ '&code_challenge_method=S256';
|
|
610
|
+
|
|
611
|
+
console.log('[mcp-client] Opening browser for ' + serverName + ' OAuth...');
|
|
612
|
+
const { execFile } = require('child_process');
|
|
613
|
+
execFile('open', [authUrl], function(err) {
|
|
614
|
+
if (err) console.error('[mcp-client] Failed to open browser:', err.message);
|
|
615
|
+
});
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
server.on('error', function(err) {
|
|
619
|
+
clearTimeout(timeout);
|
|
620
|
+
reject(new Error('OAuth server failed: ' + err.message));
|
|
621
|
+
});
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
|
|
399
625
|
module.exports = {
|
|
400
626
|
loadMcpConfigs,
|
|
401
627
|
getConnection,
|
|
628
|
+
getActiveConnections,
|
|
402
629
|
listAllTools,
|
|
403
630
|
callMcpTool,
|
|
631
|
+
authenticateMcpServer,
|
|
404
632
|
disconnectAll,
|
|
405
633
|
StdioTransport,
|
|
406
634
|
HttpTransport,
|
|
@@ -654,6 +654,39 @@ const LOCAL_TOOL_DEFINITIONS = [
|
|
|
654
654
|
required: ['url'],
|
|
655
655
|
},
|
|
656
656
|
},
|
|
657
|
+
{
|
|
658
|
+
name: 'glean_search',
|
|
659
|
+
description: 'Search company documents, wikis, Google Docs, Confluence, Jira, and other connected sources via Glean. Use when the user asks to "search docs", "find a document", "look up X in our wiki", "search Glean", or needs information from internal company knowledge bases.',
|
|
660
|
+
input_schema: {
|
|
661
|
+
type: 'object',
|
|
662
|
+
properties: {
|
|
663
|
+
query: { type: 'string', description: 'Search query (e.g., "Q4 OKRs", "onboarding guide", "API rate limits")' },
|
|
664
|
+
},
|
|
665
|
+
required: ['query'],
|
|
666
|
+
},
|
|
667
|
+
},
|
|
668
|
+
{
|
|
669
|
+
name: 'glean_people',
|
|
670
|
+
description: 'Search for people in the company directory via Glean. Use when the user asks "who is X", "who works on Y", "find someone in Z team", org chart questions, or needs contact info for a colleague.',
|
|
671
|
+
input_schema: {
|
|
672
|
+
type: 'object',
|
|
673
|
+
properties: {
|
|
674
|
+
query: { type: 'string', description: 'Person name, role, team, or skill to search for' },
|
|
675
|
+
},
|
|
676
|
+
required: ['query'],
|
|
677
|
+
},
|
|
678
|
+
},
|
|
679
|
+
{
|
|
680
|
+
name: 'glean_chat',
|
|
681
|
+
description: 'Ask Glean AI a question that requires reasoning across multiple company documents. Use for complex questions like "what was the decision on X?", "summarize our policy on Y", or "what are the pros/cons of Z according to our docs?".',
|
|
682
|
+
input_schema: {
|
|
683
|
+
type: 'object',
|
|
684
|
+
properties: {
|
|
685
|
+
question: { type: 'string', description: 'Question to ask Glean AI' },
|
|
686
|
+
},
|
|
687
|
+
required: ['question'],
|
|
688
|
+
},
|
|
689
|
+
},
|
|
657
690
|
{
|
|
658
691
|
name: 'ingest_folder',
|
|
659
692
|
description: 'Read all text files in a folder and store them in Wall-E brain as memories. Supports .md, .txt, .json, .py, .js, .csv, and many more text formats. Files are deduplicated by path. Use when the user asks to "read a folder", "ingest files", "import documents", "load my notes", or "scan a directory into brain".',
|
|
@@ -692,11 +725,43 @@ async function executeLocalTool(name, input) {
|
|
|
692
725
|
case 'mail_send': return sendMail(input);
|
|
693
726
|
case 'system_info': return getSystemInfo();
|
|
694
727
|
case 'web_fetch': return webFetch(input.url, input);
|
|
728
|
+
case 'glean_search': return gleanSearch(input);
|
|
729
|
+
case 'glean_people': return gleanPeople(input);
|
|
730
|
+
case 'glean_chat': return gleanChat(input);
|
|
695
731
|
case 'ingest_folder': return ingestFolder(input);
|
|
696
732
|
default: return null; // not a local tool
|
|
697
733
|
}
|
|
698
734
|
}
|
|
699
735
|
|
|
736
|
+
async function _callGleanMcp(toolName, args) {
|
|
737
|
+
try {
|
|
738
|
+
const mcpClient = require('../skills/mcp-client');
|
|
739
|
+
// Ensure Glean MCP is configured
|
|
740
|
+
const configs = mcpClient.loadMcpConfigs();
|
|
741
|
+
const gleanName = Object.keys(configs).find(k => k.includes('glean'));
|
|
742
|
+
if (!gleanName) return 'Glean MCP not configured. Add it to your Claude Code MCP settings.';
|
|
743
|
+
const result = await mcpClient.callMcpTool(gleanName, toolName, args);
|
|
744
|
+
if (result && result.content) {
|
|
745
|
+
return result.content.map(c => c.text || JSON.stringify(c)).join('\n');
|
|
746
|
+
}
|
|
747
|
+
return JSON.stringify(result);
|
|
748
|
+
} catch (e) {
|
|
749
|
+
return 'Glean error: ' + e.message;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
async function gleanSearch(input) {
|
|
754
|
+
return _callGleanMcp('search', { query: input.query });
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
async function gleanPeople(input) {
|
|
758
|
+
return _callGleanMcp('employee_search', { query: input.query });
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
async function gleanChat(input) {
|
|
762
|
+
return _callGleanMcp('chat', { query: input.question });
|
|
763
|
+
}
|
|
764
|
+
|
|
700
765
|
async function ingestFolder(input) {
|
|
701
766
|
const { execFileSync } = require('child_process');
|
|
702
767
|
const scriptPath = path.join(__dirname, '..', 'skills', '_bundled', 'file-ingest', 'run.js');
|