circuit-mcp 2.0.0 → 2.3.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/README.md CHANGED
@@ -1,10 +1,18 @@
1
- # Circuit MCP
1
+ # circuit-mcp
2
2
 
3
- Connect [Circuit](https://withcircuit.com) to Cursor and Claude Code via MCP (Model Context Protocol).
3
+ Customer feedback in. Engineering specs out. Solve it with your AI coding tool.
4
4
 
5
- **Circuit** transforms customer feedback into engineering specs. The MCP brings your priorities and briefs directly into your AI coding assistant.
5
+ Connect [Circuit](https://withcircuit.com) to **Cursor** and **Claude Code** via the Model Context Protocol.
6
6
 
7
- ## Quick Start
7
+ ---
8
+
9
+ ## Setup
10
+
11
+ ### Claude Code
12
+
13
+ ```bash
14
+ claude mcp add circuit -- npx circuit-mcp
15
+ ```
8
16
 
9
17
  ### Cursor
10
18
 
@@ -21,77 +29,148 @@ Add to `~/.cursor/mcp.json`:
21
29
  }
22
30
  ```
23
31
 
24
- ### Claude Code
32
+ First run opens your browser to authenticate. Token is cached at `~/.circuit/token.json` (30-day expiry).
25
33
 
26
- ```bash
27
- claude mcp add circuit -- npx circuit-mcp
28
- ```
34
+ ---
29
35
 
30
- ## First Run
36
+ ## 4 Tools
31
37
 
32
- On first use, Circuit opens your browser to authenticate. Your token is cached at `~/.circuit/token.json`.
38
+ ### `circuit.priorities`
33
39
 
34
- ```
35
- Circuit MCP
40
+ What should I work on? Ranked customer priorities with trends, confidence, and pattern matching.
36
41
 
37
- First time setup - let's connect your account.
38
- Opening browser to authenticate...
42
+ | Parameter | Type | Description |
43
+ |-----------|------|-------------|
44
+ | `weekly` | boolean | Set to `true` to get the weekly digest instead (movements, new entries, drops, spikes) |
45
+ | `lens` | string | How to rank: `volume`, `urgency`, `revenue`, `retention`, `delight`, `feature` |
46
+ | `segment` | string | Customer segment: `enterprise`, `smb`, `all` |
47
+ | `limit` | number | Number of results (default: 5, max: 20) |
48
+ | `category` | string | Filter: `Bug`, `Feature`, `Friction`, `Complaint`, `Praise` |
39
49
 
40
- Connected!
41
- ```
50
+ **Also returns:** Session context (last ship, in-progress builds, suggested next action) and instinct confidence (pattern matching from your shipping history).
51
+
52
+ **Weekly mode** (`weekly: true`): Returns priority movements (rank changes), new entries, dropped items, and volume spikes compared to last week.
53
+
54
+ ### `circuit.spec`
55
+
56
+ Full engineering spec for a priority. 5 sections: What to Build, Why It Matters, Customer Voice (verbatim quotes), Files to Touch, Done When.
57
+
58
+ | Parameter | Type | Description |
59
+ |-----------|------|-------------|
60
+ | `priority_id` | string | Priority ID from `circuit.priorities` |
61
+ | `spec_id` | string | Spec ID directly (alternative to priority_id) |
62
+ | `include_history` | boolean | Include version history and ship memory (default: true) |
63
+ | `batch` | boolean | Set to `true` to export multiple specs as markdown |
64
+ | `spec_ids` | string[] | Specific spec IDs to export (batch mode) |
65
+ | `status` | string | Filter specs by status in batch mode: `ready`, `building`, `shipped` |
66
+ | `limit` | number | Number of specs in batch mode (default: 10, max: 50) |
67
+
68
+ **Also returns:** Post-ship signal (new feedback since shipping), effort estimate, codebase context (tech stack, related PRs, AI config files like CLAUDE.md), and related memories from previous ships.
42
69
 
43
- ## Available Tools
70
+ **Batch mode** (`batch: true`): Exports multiple specs as formatted markdown for sprint planning or documentation.
44
71
 
45
- | Tool | Description |
46
- |------|-------------|
47
- | `get_priorities` | Get top customer priorities ranked by volume, urgency, or sentiment |
48
- | `get_brief` | Get the engineering spec for a priority (what to build, why, done criteria) |
49
- | `search_feedback` | Search raw customer feedback by keyword |
50
- | `start_building` | Mark a brief as "building" - you're working on it |
51
- | `mark_done` | Mark a brief as "done" - it shipped! |
72
+ ### `circuit.act`
52
73
 
53
- ## Example Usage
74
+ Take action. Start building, ship it, share back, assign, correct, submit feedback, or submit a transcript.
54
75
 
55
- Ask your AI assistant:
76
+ | Action | What it does |
77
+ |--------|-------------|
78
+ | `build` | Mark spec as "building" |
79
+ | `ship` | Mark as shipped, record memory |
80
+ | `share` | Notify customers who submitted feedback (email, widget, or both) |
81
+ | `assign` | Assign spec to a team member by email or user ID |
82
+ | `correct` | Fix a priority's category classification (Circuit remembers corrections) |
83
+ | `submit` | Add new feedback from your terminal |
84
+ | `transcript` | Submit a customer interview, sales call, or support transcript |
56
85
 
57
- > "What are my top 5 priorities?"
86
+ **Share parameters** (when `action: "share"`):
58
87
 
59
- > "Get the brief for priority #1"
88
+ | Parameter | Type | Description |
89
+ |-----------|------|-------------|
90
+ | `spec_id` | string | Spec ID (required) |
91
+ | `channel` | string | `email`, `widget`, `both`, `skip` |
92
+ | `message` | string | Custom message to include in notifications (optional) |
60
93
 
61
- > "Search feedback about login issues"
94
+ **Transcript parameters** (when `action: "transcript"`):
62
95
 
63
- > "Mark that brief as done"
96
+ | Parameter | Type | Description |
97
+ |-----------|------|-------------|
98
+ | `text` | string | Full transcript text (min 50 chars, required) |
99
+ | `title` | string | Title, e.g. "Sales call with Acme Corp" |
100
+ | `type` | string | `interview`, `sales_call`, `support`, `other` |
101
+ | `customer_name` | string | Customer name (optional) |
102
+ | `customer_email` | string | Customer email (optional, for close-the-loop notifications) |
103
+ | `revenue_band` | string | `enterprise`, `paid`, `free` |
64
104
 
65
- ## Commands
105
+ ### `circuit.ask`
106
+
107
+ Semantic search across all your data: feedback, priorities, specs, and help articles. Returns your shipping patterns and behavioral insights. Also searches your connected GitHub repo for code context.
108
+
109
+ | Parameter | Type | Description |
110
+ |-----------|------|-------------|
111
+ | `question` | string | Natural language, e.g. "What are enterprise customers saying about onboarding?" |
112
+
113
+ ---
114
+
115
+ ## Workflow
116
+
117
+ ```
118
+ > circuit.priorities {weekly: true} # What changed since last week?
119
+ > circuit.priorities # See what matters
120
+ > circuit.spec {priority_id: "..."} # Get the spec
121
+ > circuit.act {action: "build", ...} # Start building
122
+ > ... write the code ...
123
+ > circuit.act {action: "ship", ...} # Ship it.
124
+ > circuit.act {action: "share", channel: "both"} # Share back. Customers notified.
125
+ > circuit.spec {batch: true, status: "shipped"} # Export shipped specs
126
+ ```
127
+
128
+ ---
129
+
130
+ ## CLI Commands
66
131
 
67
132
  ```bash
68
- npx circuit-mcp # Start MCP server (used by Cursor/Claude)
69
- npx circuit-mcp setup # Show setup instructions
70
- npx circuit-mcp auth # Re-authenticate
71
- npx circuit-mcp logout # Clear stored token
133
+ npx circuit-mcp # Start MCP server (stdio mode)
134
+ npx circuit-mcp setup # Interactive setup
135
+ npx circuit-mcp auth # Re-authenticate
136
+ npx circuit-mcp logout # Clear stored token
137
+ npx circuit-mcp fix # Troubleshooting guide
72
138
  ```
73
139
 
74
- ## How It Works
140
+ ---
141
+
142
+ ## Troubleshooting
143
+
144
+ **"Token expired"** — Run `npx circuit-mcp auth` to re-authenticate.
145
+
146
+ **"Connection refused"** — Check your internet connection. The MCP connects to Circuit's API.
147
+
148
+ **Tool not showing in Cursor/Claude** — Restart your editor after adding the MCP config.
149
+
150
+ **Custom API URL** — Set `CIRCUIT_API_URL` environment variable to override the default endpoint.
151
+
152
+ ---
153
+
154
+ ## How it works
75
155
 
76
156
  ```
77
- Circuit (app.withcircuit.com)
78
-
79
- Feedback Priorities → Briefs
80
-
81
-
82
- Circuit MCP ◄─── Cursor / Claude Code
83
-
84
- │ get_priorities, get_brief, etc.
85
-
86
-
87
- Your AI assistant has context
157
+ Your editor (Cursor / Claude Code)
158
+ |
159
+ | JSON-RPC over stdio
160
+ |
161
+ circuit-mcp (this package)
162
+ |
163
+ | HTTPS + Bearer token
164
+ |
165
+ Circuit API
166
+ |
167
+ | PostgreSQL + pgvector
168
+ |
169
+ Your feedback data
88
170
  ```
89
171
 
90
- ## Links
91
-
92
- - [Circuit](https://withcircuit.com) - Customer feedback intelligence
93
- - [MCP Protocol](https://modelcontextprotocol.io) - Model Context Protocol spec
172
+ The MCP server runs as a local process, communicating with your editor via stdin/stdout. All data stays in your Circuit account, authenticated via OAuth 2.0.
94
173
 
95
- ## License
174
+ ---
96
175
 
97
- Proprietary - © Circuit (withcircuit.com)
176
+ Built by [Circuit](https://withcircuit.com). Feedback in. Specs out.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "circuit-mcp",
3
- "version": "2.0.0",
3
+ "version": "2.3.0",
4
4
  "description": "Connect Circuit to Cursor and Claude Code - bring customer priorities and engineering briefs into your AI coding assistant",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,57 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Circuit - Connection Failed</title>
5
+ <style>
6
+ * { margin: 0; padding: 0; box-sizing: border-box; }
7
+ body {
8
+ font-family: 'Geist', -apple-system, BlinkMacSystemFont, system-ui, 'Segoe UI', Roboto, sans-serif;
9
+ background: #F5F3F0;
10
+ min-height: 100vh;
11
+ display: flex;
12
+ align-items: center;
13
+ justify-content: center;
14
+ color: #1C1A18;
15
+ padding: 24px;
16
+ }
17
+ .content {
18
+ text-align: center;
19
+ max-width: 400px;
20
+ }
21
+ .icon {
22
+ margin-bottom: 24px;
23
+ }
24
+ .icon svg {
25
+ width: 48px;
26
+ height: 48px;
27
+ color: #D64545;
28
+ }
29
+ h1 {
30
+ font-size: 24px;
31
+ font-weight: 600;
32
+ color: #1C1A18;
33
+ margin-bottom: 12px;
34
+ }
35
+ p {
36
+ font-size: 14px;
37
+ color: rgba(28, 26, 24, 0.6);
38
+ line-height: 1.6;
39
+ }
40
+ .hint {
41
+ font-size: 12px;
42
+ color: rgba(28, 26, 24, 0.6);
43
+ margin-top: 16px;
44
+ }
45
+ </style>
46
+ </head>
47
+ <body>
48
+ <div class="content">
49
+ <div class="icon">
50
+ <svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>
51
+ </div>
52
+ <h1>Connection Failed</h1>
53
+ <p>Authentication timed out</p>
54
+ <p class="hint">Please try connecting again from your AI assistant.</p>
55
+ </div>
56
+ </body>
57
+ </html>
@@ -0,0 +1,57 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Circuit - Connected</title>
5
+ <style>
6
+ * { margin: 0; padding: 0; box-sizing: border-box; }
7
+ body {
8
+ font-family: 'Geist', -apple-system, BlinkMacSystemFont, system-ui, 'Segoe UI', Roboto, sans-serif;
9
+ background: #F5F3F0;
10
+ min-height: 100vh;
11
+ display: flex;
12
+ align-items: center;
13
+ justify-content: center;
14
+ color: #1C1A18;
15
+ padding: 24px;
16
+ }
17
+ .content {
18
+ text-align: center;
19
+ max-width: 400px;
20
+ }
21
+ .icon {
22
+ margin-bottom: 24px;
23
+ }
24
+ .icon svg {
25
+ width: 48px;
26
+ height: 48px;
27
+ color: #1C1A18;
28
+ }
29
+ h1 {
30
+ font-size: 24px;
31
+ font-weight: 600;
32
+ color: #1C1A18;
33
+ margin-bottom: 12px;
34
+ }
35
+ p {
36
+ font-size: 14px;
37
+ color: rgba(28, 26, 24, 0.6);
38
+ line-height: 1.6;
39
+ }
40
+ .hint {
41
+ font-size: 12px;
42
+ color: rgba(28, 26, 24, 0.6);
43
+ margin-top: 16px;
44
+ }
45
+ </style>
46
+ </head>
47
+ <body>
48
+ <div class="content">
49
+ <div class="icon">
50
+ <svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
51
+ </div>
52
+ <h1>Connected.</h1>
53
+ <p>Your AI coding assistant is now connected to Circuit.</p>
54
+ <p class="hint">This window will close automatically...</p>
55
+ </div>
56
+ </body>
57
+ </html>
package/src/auth.js CHANGED
@@ -8,7 +8,7 @@ import open from 'open';
8
8
  import chalk from 'chalk';
9
9
  import { showSpinner, showInfo, showPrompt } from './ui.js';
10
10
 
11
- const CIRCUIT_URL = 'https://app.withcircuit.com';
11
+ const CIRCUIT_URL = process.env.CIRCUIT_APP_URL || 'https://app.withcircuit.com';
12
12
  const TOKEN_FILE = path.join(os.homedir(), '.circuit', 'token.json');
13
13
 
14
14
  /**
@@ -136,79 +136,57 @@ function getSuccessPage() {
136
136
  return `<!DOCTYPE html>
137
137
  <html>
138
138
  <head>
139
- <title>Circuit - Connected!</title>
139
+ <title>Circuit - Connected</title>
140
140
  <style>
141
141
  * { margin: 0; padding: 0; box-sizing: border-box; }
142
142
  body {
143
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
144
- background: #F5F1EA;
143
+ font-family: 'Geist', -apple-system, BlinkMacSystemFont, system-ui, 'Segoe UI', Roboto, sans-serif;
144
+ background: #F5F3F0;
145
145
  min-height: 100vh;
146
146
  display: flex;
147
147
  align-items: center;
148
148
  justify-content: center;
149
149
  color: #1C1A18;
150
+ padding: 24px;
150
151
  }
151
- .card {
152
- background: white;
153
- border-radius: 16px;
154
- box-shadow: 0 4px 24px rgba(0,0,0,0.08);
155
- padding: 48px 64px;
152
+ .content {
156
153
  text-align: center;
157
- max-width: 420px;
154
+ max-width: 400px;
158
155
  }
159
- .logo {
160
- display: flex;
161
- align-items: center;
162
- justify-content: center;
163
- gap: 8px;
164
- margin-bottom: 32px;
165
- }
166
- .logo-icon {
167
- width: 28px;
168
- height: 28px;
169
- background: #1C1A18;
170
- border-radius: 50%;
171
- display: flex;
172
- align-items: center;
173
- justify-content: center;
174
- }
175
- .logo-icon::after {
176
- content: '';
177
- width: 12px;
178
- height: 12px;
179
- border: 2px solid white;
180
- border-radius: 50%;
156
+ .icon {
157
+ margin-bottom: 24px;
181
158
  }
182
- .logo-text {
183
- font-size: 16px;
184
- font-weight: 500;
185
- }
186
- .woo {
187
- font-size: 14px;
188
- color: rgba(28,26,24,0.5);
189
- margin-bottom: 8px;
159
+ .icon svg {
160
+ width: 48px;
161
+ height: 48px;
162
+ color: #1C1A18;
190
163
  }
191
164
  h1 {
192
- font-size: 28px;
165
+ font-size: 24px;
193
166
  font-weight: 600;
167
+ color: #1C1A18;
194
168
  margin-bottom: 12px;
195
169
  }
196
170
  p {
197
- font-size: 15px;
198
- color: rgba(28,26,24,0.6);
199
- line-height: 1.5;
171
+ font-size: 14px;
172
+ color: rgba(28, 26, 24, 0.6);
173
+ line-height: 1.6;
174
+ }
175
+ .hint {
176
+ font-size: 12px;
177
+ color: rgba(28, 26, 24, 0.6);
178
+ margin-top: 16px;
200
179
  }
201
180
  </style>
202
181
  </head>
203
182
  <body>
204
- <div class="card">
205
- <div class="logo">
206
- <div class="logo-icon"></div>
207
- <span class="logo-text">Circuit</span>
183
+ <div class="content">
184
+ <div class="icon">
185
+ <svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
208
186
  </div>
209
- <p class="woo">Woo hoo!</p>
210
- <h1>Connected to Circuit</h1>
211
- <p>You can close this window to continue Circuiting.</p>
187
+ <h1>Connected.</h1>
188
+ <p>Your AI coding assistant is now connected to Circuit.</p>
189
+ <p class="hint">This window will close automatically...</p>
212
190
  </div>
213
191
  <script>setTimeout(() => window.close(), 3000);</script>
214
192
  </body>
@@ -216,32 +194,65 @@ function getSuccessPage() {
216
194
  }
217
195
 
218
196
  function getErrorPage(error) {
197
+ // Sanitize error message to prevent XSS in the static HTML page
198
+ const safeError = String(error).replace(/[<>&"']/g, c => ({
199
+ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&#39;'
200
+ })[c]);
201
+
219
202
  return `<!DOCTYPE html>
220
203
  <html>
221
204
  <head>
222
- <title>Circuit - Error</title>
205
+ <title>Circuit - Connection Failed</title>
223
206
  <style>
224
207
  * { margin: 0; padding: 0; box-sizing: border-box; }
225
208
  body {
226
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
227
- background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
209
+ font-family: 'Geist', -apple-system, BlinkMacSystemFont, system-ui, 'Segoe UI', Roboto, sans-serif;
210
+ background: #F5F3F0;
228
211
  min-height: 100vh;
229
212
  display: flex;
230
213
  align-items: center;
231
214
  justify-content: center;
232
- color: white;
215
+ color: #1C1A18;
216
+ padding: 24px;
217
+ }
218
+ .content {
219
+ text-align: center;
220
+ max-width: 400px;
221
+ }
222
+ .icon {
223
+ margin-bottom: 24px;
224
+ }
225
+ .icon svg {
226
+ width: 48px;
227
+ height: 48px;
228
+ color: #D64545;
229
+ }
230
+ h1 {
231
+ font-size: 24px;
232
+ font-weight: 600;
233
+ color: #1C1A18;
234
+ margin-bottom: 12px;
235
+ }
236
+ p {
237
+ font-size: 14px;
238
+ color: rgba(28, 26, 24, 0.6);
239
+ line-height: 1.6;
240
+ }
241
+ .hint {
242
+ font-size: 12px;
243
+ color: rgba(28, 26, 24, 0.6);
244
+ margin-top: 16px;
233
245
  }
234
- .container { text-align: center; padding: 48px; }
235
- .icon { font-size: 64px; margin-bottom: 24px; }
236
- h1 { font-size: 32px; color: #ef4444; margin-bottom: 16px; }
237
- p { font-size: 18px; color: rgba(255,255,255,0.7); }
238
246
  </style>
239
247
  </head>
240
248
  <body>
241
- <div class="container">
242
- <div class="icon">⚠️</div>
243
- <h1>Authentication Failed</h1>
244
- <p>${error}</p>
249
+ <div class="content">
250
+ <div class="icon">
251
+ <svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>
252
+ </div>
253
+ <h1>Connection Failed</h1>
254
+ <p>${safeError}</p>
255
+ <p class="hint">Please try connecting again from your AI assistant.</p>
245
256
  </div>
246
257
  </body>
247
258
  </html>`;
package/src/server.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createInterface } from 'readline';
2
2
 
3
- const CIRCUIT_API = 'https://rnb3tf4uk3.ap-southeast-2.awsapprunner.com';
3
+ const CIRCUIT_API = process.env.CIRCUIT_API_URL || 'https://api.withcircuit.com';
4
+ const API_TIMEOUT_MS = 30_000;
4
5
 
5
6
  /**
6
7
  * Start MCP server in stdio mode
@@ -13,7 +14,6 @@ export async function startMcpServer(token) {
13
14
  terminal: false
14
15
  });
15
16
 
16
- // Handle incoming JSON-RPC messages
17
17
  rl.on('line', async (line) => {
18
18
  try {
19
19
  const message = JSON.parse(line);
@@ -22,7 +22,6 @@ export async function startMcpServer(token) {
22
22
  console.log(JSON.stringify(response));
23
23
  }
24
24
  } catch (err) {
25
- // Send error response
26
25
  console.log(JSON.stringify({
27
26
  jsonrpc: '2.0',
28
27
  error: { code: -32700, message: 'Parse error' },
@@ -31,32 +30,45 @@ export async function startMcpServer(token) {
31
30
  }
32
31
  });
33
32
 
34
- // Keep process alive
35
33
  process.stdin.resume();
36
34
  }
37
35
 
38
36
  /**
39
- * Call Circuit MCP backend API
37
+ * Call Circuit backend API with timeout
40
38
  */
41
39
  async function callMcpApi(token, toolName, args = {}) {
42
- const response = await fetch(`${CIRCUIT_API}/mcp/call`, {
43
- method: 'POST',
44
- headers: {
45
- 'Authorization': `Bearer ${token}`,
46
- 'Content-Type': 'application/json'
47
- },
48
- body: JSON.stringify({
49
- tool: toolName,
50
- arguments: args
51
- })
52
- });
40
+ const controller = new AbortController();
41
+ const timeout = setTimeout(() => controller.abort(), API_TIMEOUT_MS);
53
42
 
54
- if (!response.ok) {
55
- const text = await response.text();
56
- throw new Error(`API error ${response.status}: ${text}`);
57
- }
43
+ try {
44
+ const response = await fetch(`${CIRCUIT_API}/mcp/call`, {
45
+ method: 'POST',
46
+ headers: {
47
+ 'Authorization': `Bearer ${token}`,
48
+ 'Content-Type': 'application/json'
49
+ },
50
+ body: JSON.stringify({ tool: toolName, arguments: args }),
51
+ signal: controller.signal
52
+ });
53
+
54
+ if (response.status === 401) {
55
+ throw new Error('Token expired. Run: npx circuit-mcp auth');
56
+ }
58
57
 
59
- return response.json();
58
+ if (!response.ok) {
59
+ const text = await response.text();
60
+ throw new Error(`API error ${response.status}: ${text}`);
61
+ }
62
+
63
+ return response.json();
64
+ } catch (err) {
65
+ if (err.name === 'AbortError') {
66
+ throw new Error('Request timed out. Check your connection and try again.');
67
+ }
68
+ throw err;
69
+ } finally {
70
+ clearTimeout(timeout);
71
+ }
60
72
  }
61
73
 
62
74
  /**
@@ -72,14 +84,8 @@ async function handleMessage(message, token) {
72
84
  id,
73
85
  result: {
74
86
  protocolVersion: '2024-11-05',
75
- serverInfo: {
76
- name: 'circuit-mcp',
77
- version: '2.0.0'
78
- },
79
- capabilities: {
80
- tools: {},
81
- resources: {}
82
- }
87
+ serverInfo: { name: 'circuit-mcp', version: '2.2.0' },
88
+ capabilities: { tools: {}, resources: {} }
83
89
  }
84
90
  };
85
91
 
@@ -87,142 +93,16 @@ async function handleMessage(message, token) {
87
93
  return null;
88
94
 
89
95
  case 'tools/list':
90
- return {
91
- jsonrpc: '2.0',
92
- id,
93
- result: {
94
- tools: [
95
- {
96
- name: 'circuit.priorities',
97
- description: 'What should I work on? Get ranked priorities with confidence indicators, trends, and memory context.',
98
- inputSchema: {
99
- type: 'object',
100
- properties: {
101
- lens: {
102
- type: 'string',
103
- description: "Focus lens: 'volume', 'urgency', 'revenue', 'retention', 'delight', 'feature'",
104
- enum: ['volume', 'urgency', 'revenue', 'retention', 'delight', 'feature'],
105
- default: 'volume'
106
- },
107
- segment: {
108
- type: 'string',
109
- description: "Filter by customer segment: 'enterprise', 'smb', 'all'",
110
- default: 'all'
111
- },
112
- limit: {
113
- type: 'number',
114
- description: 'Number of priorities (default: 5, max: 20)',
115
- default: 5
116
- },
117
- category: {
118
- type: 'string',
119
- description: "Filter by category: 'Bug', 'Feature', 'Friction', 'Complaint', 'Praise'",
120
- enum: ['Bug', 'Feature', 'Friction', 'Complaint', 'Praise']
121
- }
122
- }
123
- }
124
- },
125
- {
126
- name: 'circuit.brief',
127
- description: 'Get the full engineering spec for a priority. Includes brief content, customer context, version history, and related memory (previous ships, outcomes).',
128
- inputSchema: {
129
- type: 'object',
130
- properties: {
131
- priority_id: {
132
- type: 'string',
133
- description: 'The priority ID'
134
- },
135
- build_id: {
136
- type: 'string',
137
- description: 'The build ID directly (alternative to priority_id)'
138
- },
139
- include_history: {
140
- type: 'boolean',
141
- description: 'Include version history and related memory',
142
- default: true
143
- }
144
- }
145
- }
146
- },
147
- {
148
- name: 'circuit.act',
149
- description: 'Take an action in Circuit. Ship a brief, start building, correct a classification, or submit new feedback.',
150
- inputSchema: {
151
- type: 'object',
152
- properties: {
153
- action: {
154
- type: 'string',
155
- description: "Action to take: 'build' (start building), 'ship' (mark shipped), 'correct' (fix classification), 'submit' (add feedback)",
156
- enum: ['build', 'ship', 'correct', 'submit']
157
- },
158
- brief_id: {
159
- type: 'string',
160
- description: "Brief ID (for 'build' and 'ship' actions)"
161
- },
162
- priority_id: {
163
- type: 'string',
164
- description: "Priority ID (for 'correct' action)"
165
- },
166
- correction_type: {
167
- type: 'string',
168
- description: "What to correct (for 'correct' action): 'category'",
169
- enum: ['category']
170
- },
171
- original: {
172
- type: 'string',
173
- description: 'Original value being corrected'
174
- },
175
- corrected: {
176
- type: 'string',
177
- description: 'New corrected value'
178
- },
179
- feedback: {
180
- type: 'string',
181
- description: "Feedback text (for 'submit' action)"
182
- },
183
- source: {
184
- type: 'string',
185
- description: "Feedback source (for 'submit' action)",
186
- default: 'mcp'
187
- }
188
- },
189
- required: ['action']
190
- }
191
- },
192
- {
193
- name: 'circuit.ask',
194
- description: 'Ask anything about your feedback data. Searches across feedback, priorities, briefs, and help articles using semantic search.',
195
- inputSchema: {
196
- type: 'object',
197
- properties: {
198
- question: {
199
- type: 'string',
200
- description: "Natural language question (e.g., 'What are enterprise customers complaining about?', 'How do briefs work?')"
201
- }
202
- },
203
- required: ['question']
204
- }
205
- }
206
- ]
207
- }
208
- };
96
+ return { jsonrpc: '2.0', id, result: { tools: TOOLS } };
209
97
 
210
98
  case 'tools/call':
211
99
  return await handleToolCall(id, params, token);
212
100
 
213
101
  case 'resources/list':
214
- return {
215
- jsonrpc: '2.0',
216
- id,
217
- result: { resources: [] }
218
- };
102
+ return { jsonrpc: '2.0', id, result: { resources: [] } };
219
103
 
220
104
  case 'ping':
221
- return {
222
- jsonrpc: '2.0',
223
- id,
224
- result: {}
225
- };
105
+ return { jsonrpc: '2.0', id, result: {} };
226
106
 
227
107
  default:
228
108
  return {
@@ -233,96 +113,264 @@ async function handleMessage(message, token) {
233
113
  }
234
114
  }
235
115
 
236
- /**
237
- * Format priorities response
238
- */
239
- function formatPriorities(data) {
240
- if (data.message) {
241
- return data.message;
116
+ // ─────────────────────────────────────────────────────────────
117
+ // Tool Definitions
118
+ // ─────────────────────────────────────────────────────────────
119
+
120
+ const TOOLS = [
121
+ {
122
+ name: 'circuit.priorities',
123
+ description: 'What should I work on? Ranked customer priorities with trend data, confidence indicators, and pattern matching. Set weekly: true to get the weekly digest instead.',
124
+ inputSchema: {
125
+ type: 'object',
126
+ properties: {
127
+ weekly: {
128
+ type: 'boolean',
129
+ description: 'Set to true to get the weekly digest: priority movements, new entries, dropped items, and volume spikes compared to last week'
130
+ },
131
+ lens: {
132
+ type: 'string',
133
+ description: "How to rank: 'volume' (most users), 'urgency' (bugs & quality), 'revenue' (revenue impact), 'retention' (negative sentiment), 'delight' (positive sentiment), 'feature' (new feature requests)",
134
+ enum: ['volume', 'urgency', 'revenue', 'retention', 'delight', 'feature'],
135
+ default: 'volume'
136
+ },
137
+ segment: {
138
+ type: 'string',
139
+ description: "Customer segment filter: 'enterprise', 'smb', 'all'",
140
+ default: 'all'
141
+ },
142
+ limit: {
143
+ type: 'number',
144
+ description: 'Number of priorities to return (default: 5, max: 20)',
145
+ default: 5
146
+ },
147
+ category: {
148
+ type: 'string',
149
+ description: "Filter by feedback category",
150
+ enum: ['Bug', 'Feature', 'Improvement', 'Praise']
151
+ }
152
+ }
153
+ }
154
+ },
155
+ {
156
+ name: 'circuit.spec',
157
+ description: 'Full engineering spec for a priority. 5 sections: What to Build, Why It Matters, Customer Voice, Files to Touch, Done When. Set batch: true to export multiple specs as markdown.',
158
+ inputSchema: {
159
+ type: 'object',
160
+ properties: {
161
+ priority_id: {
162
+ type: 'string',
163
+ description: 'The priority ID (from circuit.priorities)'
164
+ },
165
+ spec_id: {
166
+ type: 'string',
167
+ description: 'The spec ID directly (alternative to priority_id)'
168
+ },
169
+ include_history: {
170
+ type: 'boolean',
171
+ description: 'Include version history and related ship memory',
172
+ default: true
173
+ },
174
+ batch: {
175
+ type: 'boolean',
176
+ description: 'Set to true to export multiple specs as markdown for sprint planning'
177
+ },
178
+ spec_ids: {
179
+ type: 'array',
180
+ items: { type: 'string' },
181
+ description: 'Specific spec IDs to export (batch mode). If empty, exports recent specs.'
182
+ },
183
+ status: {
184
+ type: 'string',
185
+ description: "Filter by status (batch mode): 'ready', 'building', 'shipped'",
186
+ enum: ['ready', 'building', 'shipped', 'done']
187
+ },
188
+ limit: {
189
+ type: 'number',
190
+ description: 'Number of specs to export in batch mode (default: 10, max: 50)',
191
+ default: 10
192
+ }
193
+ }
194
+ }
195
+ },
196
+ {
197
+ name: 'circuit.act',
198
+ description: "Take action. Start building, ship it, share back with customers, assign, correct a classification, submit feedback, or submit a transcript.",
199
+ inputSchema: {
200
+ type: 'object',
201
+ properties: {
202
+ action: {
203
+ type: 'string',
204
+ description: "'build' (start building), 'ship' (mark shipped), 'share' (notify customers via email/widget), 'assign' (assign to team member), 'correct' (fix classification), 'submit' (add feedback), 'transcript' (submit a transcript)",
205
+ enum: ['build', 'ship', 'share', 'assign', 'correct', 'submit', 'transcript']
206
+ },
207
+ spec_id: {
208
+ type: 'string',
209
+ description: "Spec ID (for build, ship, share, assign)"
210
+ },
211
+ channel: {
212
+ type: 'string',
213
+ description: "Notification channel for share: 'email', 'widget', 'both', 'skip'",
214
+ enum: ['email', 'widget', 'both', 'skip']
215
+ },
216
+ message: {
217
+ type: 'string',
218
+ description: "Custom message to include in share notifications (optional)"
219
+ },
220
+ assigned_to: {
221
+ type: 'string',
222
+ description: "Team member email or user ID (for assign). Empty to unassign."
223
+ },
224
+ priority_id: {
225
+ type: 'string',
226
+ description: "Priority ID (for correct)"
227
+ },
228
+ correction_type: {
229
+ type: 'string',
230
+ description: "What to correct: 'category'",
231
+ enum: ['category']
232
+ },
233
+ original: {
234
+ type: 'string',
235
+ description: 'Original value being corrected'
236
+ },
237
+ corrected: {
238
+ type: 'string',
239
+ description: 'New corrected value'
240
+ },
241
+ feedback: {
242
+ type: 'string',
243
+ description: "Feedback text (for submit)"
244
+ },
245
+ source: {
246
+ type: 'string',
247
+ description: "Feedback source (for submit)",
248
+ default: 'mcp'
249
+ },
250
+ text: {
251
+ type: 'string',
252
+ description: 'Full transcript text, min 50 chars (for transcript)'
253
+ },
254
+ title: {
255
+ type: 'string',
256
+ description: "Transcript title, e.g. 'Sales call with Acme Corp' (for transcript)"
257
+ },
258
+ type: {
259
+ type: 'string',
260
+ description: "Transcript type (for transcript)",
261
+ enum: ['interview', 'sales_call', 'support', 'other']
262
+ },
263
+ customer_name: {
264
+ type: 'string',
265
+ description: 'Customer name (for transcript, optional)'
266
+ },
267
+ customer_email: {
268
+ type: 'string',
269
+ description: 'Customer email (for transcript, optional)'
270
+ },
271
+ revenue_band: {
272
+ type: 'string',
273
+ description: "Customer segment (for transcript): 'enterprise', 'paid', 'free'",
274
+ enum: ['enterprise', 'paid', 'free']
275
+ }
276
+ },
277
+ required: ['action']
278
+ }
279
+ },
280
+ {
281
+ name: 'circuit.ask',
282
+ description: 'Search across all your feedback data. Semantic search over feedback, priorities, briefs, and help articles. Also returns your shipping patterns and behavioral insights.',
283
+ inputSchema: {
284
+ type: 'object',
285
+ properties: {
286
+ question: {
287
+ type: 'string',
288
+ description: "Natural language question, e.g. 'What are enterprise customers complaining about?'"
289
+ }
290
+ },
291
+ required: ['question']
292
+ }
242
293
  }
294
+ ];
243
295
 
244
- if (!data.priorities || data.priorities.length === 0) {
245
- return 'No priorities found. Upload feedback to Circuit to get started.';
296
+ // ─────────────────────────────────────────────────────────────
297
+ // Response Formatting
298
+ // ─────────────────────────────────────────────────────────────
299
+
300
+ function formatPriorities(data) {
301
+ // Weekly digest mode — detected by presence of movements/new_entries/headline
302
+ if (data.headline !== undefined || data.movements !== undefined) {
303
+ return formatWeeklyDigest(data);
246
304
  }
247
305
 
306
+ if (data.message) return data.message;
307
+ if (!data.priorities?.length) return 'No priorities found. Upload feedback to get started.';
308
+
248
309
  const lines = [];
249
310
 
250
311
  if (data.memory_applied) {
251
- lines.push(`*Memory active: ${data.ships_count} ships tracked, segment: ${data.segment_affinity || 'mixed'}*\n`);
312
+ lines.push(`*${data.ships_count} ships tracked · segment: ${data.segment_affinity || 'mixed'}*\n`);
252
313
  }
253
314
 
254
315
  for (const p of data.priorities) {
255
- let trendText = '';
256
- if (p.trend === 'up') {
257
- trendText = ` ↑${p.trend_percent ? ` ${p.trend_percent}%` : ''}`;
258
- } else if (p.trend === 'down') {
259
- trendText = ` ↓${p.trend_percent ? ` ${p.trend_percent}%` : ''}`;
260
- }
261
-
262
316
  lines.push(`**#${p.rank}. ${p.theme}**`);
263
317
 
264
- const badges = [];
265
- badges.push(p.category || 'Other');
266
- badges.push(`${p.volume} users`);
267
- if (trendText) badges.push(trendText.trim());
318
+ const meta = [p.category || 'Other', `${p.volume} users`];
319
+ if (p.trend === 'up') meta.push(`↑${p.trend_percent ? ` ${p.trend_percent}%` : ''}`);
320
+ if (p.trend === 'down') meta.push(`↓${p.trend_percent ? ` ${p.trend_percent}%` : ''}`);
268
321
  if (p.brief_status && p.brief_status !== 'no_brief') {
269
- const statusLabel = { ready: 'Ready', building: 'Building', shipped: 'Shipped' }[p.brief_status];
270
- if (statusLabel) badges.push(statusLabel);
271
- }
272
- if (p.matches_pattern) badges.push('matches pattern');
273
- if (p.version) badges.push(p.version);
274
-
275
- lines.push(badges.join(' · '));
276
-
277
- if (p.key_quote) {
278
- lines.push(`> "${p.key_quote}"`);
322
+ const label = { ready: 'Ready', building: 'Building', shipped: 'Shipped' }[p.brief_status];
323
+ if (label) meta.push(label);
279
324
  }
325
+ if (p.matches_pattern) meta.push('matches pattern');
326
+ if (p.version) meta.push(p.version);
327
+ lines.push(meta.join(' · '));
280
328
 
281
- lines.push(`priority_id: \`${p.priority_id}\`${p.build_id ? ` · build_id: \`${p.build_id}\`` : ''}`);
329
+ if (p.key_quote) lines.push(`> "${p.key_quote}"`);
330
+ lines.push(`priority_id: \`${p.priority_id}\`${p.build_id ? ` · spec_id: \`${p.build_id}\`` : ''}`);
282
331
  lines.push('');
283
332
  }
284
333
 
285
334
  lines.push('---');
286
- lines.push('Use `circuit.brief` with a priority_id to see the full engineering spec.');
335
+ lines.push('Use `circuit.spec` with a priority_id to get the full spec.');
287
336
 
288
337
  return lines.join('\n');
289
338
  }
290
339
 
291
- /**
292
- * Format brief response
293
- */
294
- function formatBrief(data) {
340
+ function formatSpec(data) {
341
+ // Batch export mode — detected by presence of markdown/count
342
+ if (data.markdown !== undefined) {
343
+ return formatBatchExport(data);
344
+ }
345
+
295
346
  if (data.error) {
296
347
  if (data.error === 'no_brief' && data.priority) {
297
348
  const p = data.priority;
298
- let output = `# ${p.theme || 'Priority'}\n\n`;
299
- output += `${p.category || 'Other'} · ${p.volume || 0} users\n\n`;
300
- output += `**No brief generated yet.**\n\n`;
301
- if (data.suggestion) {
302
- output += `${data.suggestion}\n`;
303
- }
304
- return output;
349
+ return `# ${p.theme || 'Priority'}\n\n${p.category || 'Other'} · ${p.volume || 0} users\n\n**No brief generated yet.**\n\n${data.suggestion || ''}`;
305
350
  }
306
351
  return `Error: ${data.message || data.error}`;
307
352
  }
308
353
 
309
354
  const spec = data.spec_content || '';
310
- let output = '';
311
-
312
355
  const title = data.title || 'Engineering Brief';
313
- output += `# ${title}\n\n`;
356
+ const status = { ready: 'Ready', building: 'Building', shipped: 'Shipped' }[data.status] || data.status;
357
+ const version = data.version_badge ? ` · ${data.version_badge}` : '';
314
358
 
315
- const statusText = { ready: 'Ready', building: 'Building', shipped: 'Shipped' }[data.status] || data.status;
316
- const versionBadge = data.version_badge ? ` · ${data.version_badge}` : '';
317
- output += `Status: ${statusText}${versionBadge}\n`;
359
+ let output = `# ${title}\n\nStatus: ${status}${version}\n`;
318
360
 
319
361
  if (data.customer_context) {
320
362
  const ctx = data.customer_context;
321
363
  output += `${ctx.category} · ${ctx.volume} users · ${ctx.paying_percent}% paying\n`;
322
364
  }
365
+
366
+ if (data.effort) {
367
+ const e = data.effort;
368
+ output += `Effort: ${e.files} file${e.files !== 1 ? 's' : ''} · ${e.label}\n`;
369
+ }
370
+
323
371
  output += '\n';
324
372
 
325
- let cleanSpec = spec
373
+ output += spec
326
374
  .replace(/<what_to_build>/gi, '## WHAT TO BUILD\n')
327
375
  .replace(/<\/what_to_build>/gi, '\n')
328
376
  .replace(/<why_it_matters>/gi, '## WHY IT MATTERS\n')
@@ -334,39 +382,31 @@ function formatBrief(data) {
334
382
  .replace(/<done_when>/gi, '## DONE WHEN\n')
335
383
  .replace(/<\/done_when>/gi, '\n');
336
384
 
337
- output += cleanSpec;
338
-
339
- if (data.related_memory && data.related_memory.length > 0) {
385
+ if (data.related_memory?.length > 0) {
340
386
  output += '\n## WHAT CIRCUIT REMEMBERS\n\n';
341
387
  for (const mem of data.related_memory) {
342
- if (mem.type === 'ship') {
343
- output += `- Shipped: ${mem.theme || mem.summary || 'Related feature'}\n`;
344
- } else if (mem.type === 'correction') {
345
- output += `- Correction: ${mem.summary || 'Classification adjusted'}\n`;
346
- } else {
347
- output += `- ${mem.summary || JSON.stringify(mem)}\n`;
348
- }
388
+ output += `- ${mem.summary || mem.theme || JSON.stringify(mem)}\n`;
349
389
  }
350
390
  }
351
391
 
352
- output += `\n---\n`;
353
- output += `build_id: \`${data.build_id}\``;
392
+ output += `\n---\nspec_id: \`${data.build_id}\``;
354
393
 
355
394
  return output;
356
395
  }
357
396
 
358
- /**
359
- * Format act response
360
- */
361
397
  function formatAct(data) {
362
- if (data.error) {
363
- return `Error: ${data.error}`;
364
- }
398
+ if (data.error) return `Error: ${data.error}`;
365
399
 
366
400
  if (data.success) {
367
401
  let output = data.message;
368
402
  if (data.memory_created) {
369
- output += '\nShip memory recorded Circuit will remember this for future briefs.';
403
+ output += '\nShip memory recorded. Circuit will remember this for future specs.';
404
+ }
405
+ if (data.transcript_id) {
406
+ output += `\n\ntranscript_id: \`${data.transcript_id}\``;
407
+ }
408
+ if (data.spec_id && data.action === 'share') {
409
+ output += `\n\nspec_id: \`${data.spec_id}\``;
370
410
  }
371
411
  return output;
372
412
  }
@@ -374,17 +414,12 @@ function formatAct(data) {
374
414
  return JSON.stringify(data, null, 2);
375
415
  }
376
416
 
377
- /**
378
- * Format ask response
379
- */
380
417
  function formatAsk(data) {
381
- if (data.message) {
382
- return data.message;
383
- }
418
+ if (data.message) return data.message;
384
419
 
385
420
  const lines = [];
386
421
 
387
- if (data.help_articles && data.help_articles.length > 0) {
422
+ if (data.help_articles?.length > 0) {
388
423
  lines.push('**Help Articles:**');
389
424
  for (const a of data.help_articles) {
390
425
  lines.push(`- **${a.title}**: ${a.content}`);
@@ -392,15 +427,15 @@ function formatAsk(data) {
392
427
  lines.push('');
393
428
  }
394
429
 
395
- if (data.priorities && data.priorities.length > 0) {
430
+ if (data.priorities?.length > 0) {
396
431
  lines.push('**Related Priorities:**');
397
432
  for (const p of data.priorities) {
398
- lines.push(`- ${p.theme} (${p.category}, ${p.volume} users) — priority_id: \`${p.id}\``);
433
+ lines.push(`- ${p.theme} (${p.category}, ${p.volume} users) — \`${p.id}\``);
399
434
  }
400
435
  lines.push('');
401
436
  }
402
437
 
403
- if (data.feedback && data.feedback.length > 0) {
438
+ if (data.feedback?.length > 0) {
404
439
  lines.push('**Related Feedback:**');
405
440
  for (const f of data.feedback) {
406
441
  lines.push(`- [${f.source}] "${f.text}"`);
@@ -415,7 +450,7 @@ function formatAsk(data) {
415
450
  lines.push(`- Segment: ${pat.segment_affinity}`);
416
451
  if (pat.top_categories) {
417
452
  const cats = Object.entries(pat.top_categories).map(([k, v]) => `${k} (${Math.round(v * 100)}%)`).join(', ');
418
- lines.push(`- Top categories: ${cats}`);
453
+ lines.push(`- Categories: ${cats}`);
419
454
  }
420
455
  lines.push('');
421
456
  }
@@ -424,45 +459,95 @@ function formatAsk(data) {
424
459
  return `No results found for "${data.question}". Try rephrasing.`;
425
460
  }
426
461
 
427
- lines.push(`---\n${data.total} results found`);
462
+ lines.push(`---\n${data.total} results`);
463
+ return lines.join('\n');
464
+ }
465
+
466
+ // Internal helper: format weekly digest data (called from formatPriorities when weekly: true)
467
+ function formatWeeklyDigest(data) {
468
+ if (data.error) return `Error: ${data.error}`;
469
+
470
+ const lines = [];
471
+
472
+ lines.push(`**${data.headline || 'Weekly Digest'}**\n`);
473
+
474
+ if (data.movements?.length > 0) {
475
+ lines.push('**Movements:**');
476
+ for (const m of data.movements) {
477
+ const arrow = m.direction === 'up' ? '↑' : '↓';
478
+ lines.push(`- ${arrow} ${m.title} (#${m.from_rank} → #${m.to_rank})`);
479
+ }
480
+ lines.push('');
481
+ }
482
+
483
+ if (data.new_entries?.length > 0) {
484
+ lines.push('**New This Week:**');
485
+ for (const n of data.new_entries) {
486
+ lines.push(`- ${n.title} (rank #${n.rank}, ${n.mentions} mentions)`);
487
+ }
488
+ lines.push('');
489
+ }
490
+
491
+ if (data.volume_spikes?.length > 0) {
492
+ lines.push('**Volume Spikes:**');
493
+ for (const v of data.volume_spikes) {
494
+ lines.push(`- ${v.title} (+${v.percent_increase}%)`);
495
+ }
496
+ lines.push('');
497
+ }
498
+
499
+ if (data.dropped?.length > 0) {
500
+ lines.push('**Dropped Off:**');
501
+ for (const d of data.dropped) {
502
+ lines.push(`- ${d.title}${d.reason ? ` (${d.reason})` : ''}`);
503
+ }
504
+ lines.push('');
505
+ }
506
+
507
+ if (!data.has_previous) {
508
+ lines.push('*First week — baseline captured. Changes will appear next week.*');
509
+ }
510
+
511
+ if (lines.length <= 1) {
512
+ return data.headline || 'No changes this week.';
513
+ }
428
514
 
429
515
  return lines.join('\n');
430
516
  }
431
517
 
432
- /**
433
- * Handle tool calls
434
- */
518
+ // Internal helper: format batch export data (called from formatSpec when batch: true)
519
+ function formatBatchExport(data) {
520
+ if (data.error) return `Error: ${data.error}${data.suggestion ? `\n${data.suggestion}` : ''}`;
521
+
522
+ let output = `**${data.count} brief${data.count !== 1 ? 's' : ''} exported**\n\n`;
523
+ output += data.markdown;
524
+ return output;
525
+ }
526
+
527
+ // ─────────────────────────────────────────────────────────────
528
+ // Tool Call Handler
529
+ // ─────────────────────────────────────────────────────────────
530
+
435
531
  async function handleToolCall(id, params, token) {
436
532
  const { name, arguments: args } = params;
437
533
 
438
534
  try {
439
535
  const result = await callMcpApi(token, name, args || {});
440
536
 
441
- let formattedText;
442
-
443
- switch (name) {
444
- case 'circuit.priorities':
445
- formattedText = formatPriorities(result);
446
- break;
447
- case 'circuit.brief':
448
- formattedText = formatBrief(result);
449
- break;
450
- case 'circuit.act':
451
- formattedText = formatAct(result);
452
- break;
453
- case 'circuit.ask':
454
- formattedText = formatAsk(result);
455
- break;
456
- default:
457
- formattedText = JSON.stringify(result, null, 2);
458
- }
537
+ const formatters = {
538
+ 'circuit.priorities': formatPriorities,
539
+ 'circuit.spec': formatSpec,
540
+ 'circuit.act': formatAct,
541
+ 'circuit.ask': formatAsk,
542
+ };
543
+
544
+ const formatter = formatters[name] || ((d) => JSON.stringify(d, null, 2));
545
+ const text = formatter(result);
459
546
 
460
547
  return {
461
548
  jsonrpc: '2.0',
462
549
  id,
463
- result: {
464
- content: [{ type: 'text', text: formattedText }]
465
- }
550
+ result: { content: [{ type: 'text', text }] }
466
551
  };
467
552
 
468
553
  } catch (err) {