@ghl-ai/aw 0.1.85 → 0.1.88
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/cli.mjs +2 -1
- package/clickup-sync.mjs +45 -2
- package/commands/clickup-sync.mjs +9 -4
- package/commands/push.mjs +28 -1
- package/integrations/clickup-proxy.mjs +63 -0
- package/integrations/clickup.mjs +42 -2
- package/integrations/mcp-rpc.mjs +218 -0
- package/mcp.mjs +2 -2
- package/package.json +1 -1
package/cli.mjs
CHANGED
|
@@ -183,8 +183,9 @@ function printHelp() {
|
|
|
183
183
|
cmd('aw integrations add <key>', 'Install a specific tool (e.g. codex, caveman, context-mode)'),
|
|
184
184
|
cmd('aw integrations remove <key>', 'Remove a tool'),
|
|
185
185
|
cmd('aw integrations bundle <name>', 'Install a preset bundle'),
|
|
186
|
-
cmd('aw clickup-sync <items.json> --list <id>', 'Sync JSON items into a ClickUp list (create new, comment recurring; input-validated, fail-closed)'),
|
|
186
|
+
cmd('aw clickup-sync <items.json> --list <id>', 'Sync JSON items into a ClickUp list via the ghl-ai MCP proxy using your GitHub PAT (no CLICKUP_API_TOKEN needed; create new, comment recurring; optional per-item tags + priority 1-4; input-validated, fail-closed)'),
|
|
187
187
|
cmd('aw clickup-sync <items.json> --list <id> --dry-run', 'Preview the create/comment plan without any writes'),
|
|
188
|
+
cmd('aw clickup-sync <items.json> --list <id> --direct', 'Bypass the proxy and call ClickUp directly with a local CLICKUP_API_TOKEN'),
|
|
188
189
|
cmd('aw drop <path>', 'Stop syncing or delete local content'),
|
|
189
190
|
cmd('aw nuke', 'Remove entire .aw_registry/ & start fresh'),
|
|
190
191
|
cmd('aw daemon install', 'Auto-pull on a schedule (macOS launchd / Linux cron)'),
|
package/clickup-sync.mjs
CHANGED
|
@@ -25,7 +25,14 @@ export const LEGACY_NAME_KEY_RE = /key:(\S+)\s*$/;
|
|
|
25
25
|
export const TITLE_MAX = 255;
|
|
26
26
|
export const DESC_MAX = 8000;
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
// Tier 1 enrichment bounds. Tags are ClickUp tag names (attached at create);
|
|
29
|
+
// priority is ClickUp's fixed 1..4 scale (1=Urgent, 2=High, 3=Normal, 4=Low).
|
|
30
|
+
export const TAG_MAX = 100;
|
|
31
|
+
export const TAGS_MAX = 20;
|
|
32
|
+
export const PRIORITY_MIN = 1;
|
|
33
|
+
export const PRIORITY_MAX = 4;
|
|
34
|
+
|
|
35
|
+
const ALLOWED_FIELDS = new Set(['key', 'title', 'description', 'tags', 'priority']);
|
|
29
36
|
|
|
30
37
|
/**
|
|
31
38
|
* Validate untrusted, already-parsed JSON into a normalized item list.
|
|
@@ -78,7 +85,43 @@ export function parseAndValidateItems(raw) {
|
|
|
78
85
|
throw new Error(`Invalid item ${index}: "description" exceeds ${DESC_MAX} characters.`);
|
|
79
86
|
}
|
|
80
87
|
|
|
81
|
-
|
|
88
|
+
const normalized = { key, title, description };
|
|
89
|
+
|
|
90
|
+
// Optional tags: array of non-empty tag-name strings, deduped, bounded.
|
|
91
|
+
if (item.tags !== undefined) {
|
|
92
|
+
if (!Array.isArray(item.tags)) {
|
|
93
|
+
throw new Error(`Invalid item ${index}: "tags" must be an array of strings when present.`);
|
|
94
|
+
}
|
|
95
|
+
const tags = [];
|
|
96
|
+
const seenTags = new Set();
|
|
97
|
+
for (const tag of item.tags) {
|
|
98
|
+
if (typeof tag !== 'string' || tag.trim().length === 0) {
|
|
99
|
+
throw new Error(`Invalid item ${index}: every "tag" must be a non-empty string.`);
|
|
100
|
+
}
|
|
101
|
+
if (tag.length > TAG_MAX) {
|
|
102
|
+
throw new Error(`Invalid item ${index}: a "tag" exceeds ${TAG_MAX} characters.`);
|
|
103
|
+
}
|
|
104
|
+
if (seenTags.has(tag)) continue;
|
|
105
|
+
seenTags.add(tag);
|
|
106
|
+
tags.push(tag);
|
|
107
|
+
}
|
|
108
|
+
if (tags.length > TAGS_MAX) {
|
|
109
|
+
throw new Error(`Invalid item ${index}: "tags" exceeds ${TAGS_MAX} entries.`);
|
|
110
|
+
}
|
|
111
|
+
normalized.tags = tags;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Optional priority: integer on ClickUp's 1..4 scale.
|
|
115
|
+
if (item.priority !== undefined) {
|
|
116
|
+
const { priority } = item;
|
|
117
|
+
if (typeof priority !== 'number' || !Number.isInteger(priority)
|
|
118
|
+
|| priority < PRIORITY_MIN || priority > PRIORITY_MAX) {
|
|
119
|
+
throw new Error(`Invalid item ${index}: "priority" must be an integer ${PRIORITY_MIN}..${PRIORITY_MAX} (1=Urgent, 2=High, 3=Normal, 4=Low).`);
|
|
120
|
+
}
|
|
121
|
+
normalized.priority = priority;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return normalized;
|
|
82
125
|
});
|
|
83
126
|
|
|
84
127
|
return items;
|
|
@@ -11,14 +11,14 @@
|
|
|
11
11
|
import { readFileSync } from 'node:fs';
|
|
12
12
|
import * as fmt from '../fmt.mjs';
|
|
13
13
|
import { chalk } from '../fmt.mjs';
|
|
14
|
-
import {
|
|
14
|
+
import { resolveClickUpClient } from '../integrations/clickup.mjs';
|
|
15
15
|
import { parseAndValidateItems, embedSentinel, computeSyncPlan } from '../clickup-sync.mjs';
|
|
16
16
|
|
|
17
17
|
const RECURRING_COMMENT = 'Recurred via `aw clickup-sync` — item seen again in the source list.';
|
|
18
18
|
|
|
19
19
|
export async function clickupSyncCommand(args, deps = {}) {
|
|
20
20
|
const readFile = deps.readFile ?? ((path) => readFileSync(path, 'utf8'));
|
|
21
|
-
const makeClient = deps.createClient ?? (() =>
|
|
21
|
+
const makeClient = deps.createClient ?? (() => resolveClickUpClient(args));
|
|
22
22
|
|
|
23
23
|
fmt.configureInteractionMode(args);
|
|
24
24
|
fmt.intro('aw clickup-sync');
|
|
@@ -91,10 +91,15 @@ export async function clickupSyncCommand(args, deps = {}) {
|
|
|
91
91
|
let created = 0;
|
|
92
92
|
for (const item of toCreate) {
|
|
93
93
|
try {
|
|
94
|
-
|
|
94
|
+
const payload = {
|
|
95
95
|
name: item.title,
|
|
96
96
|
description: embedSentinel(item.description, item.key),
|
|
97
|
-
}
|
|
97
|
+
};
|
|
98
|
+
// Enrichment fields are create-time only and included solely when present,
|
|
99
|
+
// so items without them behave exactly as before (no empty tags / undefined priority).
|
|
100
|
+
if (item.tags !== undefined) payload.tags = item.tags;
|
|
101
|
+
if (item.priority !== undefined) payload.priority = item.priority;
|
|
102
|
+
await client.createTask(listId, payload);
|
|
98
103
|
created++;
|
|
99
104
|
} catch (err) {
|
|
100
105
|
fmt.cancel(`Failed creating task for "${item.key}" after ${created} created (re-run to resume): ${errMessage(err)}`);
|
package/commands/push.mjs
CHANGED
|
@@ -550,6 +550,20 @@ async function getGitStatus(repoDir) {
|
|
|
550
550
|
return stdout;
|
|
551
551
|
}
|
|
552
552
|
|
|
553
|
+
/**
|
|
554
|
+
* Hard-restore a reused docs cache clone to a clean tree. The clone is a
|
|
555
|
+
* disposable publish staging area (re-checked-out from origin and re-populated
|
|
556
|
+
* on every publish), so discarding local working-tree changes is safe and
|
|
557
|
+
* prevents a single aborted publish from permanently wedging the cache.
|
|
558
|
+
* Best-effort: swallow git errors so recovery never itself blocks a publish.
|
|
559
|
+
*/
|
|
560
|
+
async function recoverDirtyDocsCache(cloneDir) {
|
|
561
|
+
await execFile('git', ['reset', '--hard'], { cwd: cloneDir, encoding: 'utf8' })
|
|
562
|
+
.catch((e) => fmt.logWarn(`AW docs cache reset failed: ${(e?.stderr || e?.message || '').toString().trim()}`));
|
|
563
|
+
await execFile('git', ['clean', '-fd'], { cwd: cloneDir, encoding: 'utf8' })
|
|
564
|
+
.catch((e) => fmt.logWarn(`AW docs cache clean failed: ${(e?.stderr || e?.message || '').toString().trim()}`));
|
|
565
|
+
}
|
|
566
|
+
|
|
553
567
|
function parseGitHubRepo(remoteUrl) {
|
|
554
568
|
const match = remoteUrl.trim().match(/github\.com[:/]([^/]+)\/(.+?)(?:\.git)?$/);
|
|
555
569
|
if (!match) return null;
|
|
@@ -654,8 +668,21 @@ async function ensureAwDocsRepoClone(home, publishConfig, sparseIncludePaths = [
|
|
|
654
668
|
status = await getGitStatus(cloneDir);
|
|
655
669
|
}
|
|
656
670
|
if (status.trim()) {
|
|
671
|
+
// The cached docs clone is a disposable staging area — below we re-checkout
|
|
672
|
+
// the publish branch from origin and write fresh content, so any remaining
|
|
673
|
+
// local dirt is safe to discard. This is most often leftover staged/modified
|
|
674
|
+
// files from a PRIOR aborted publish; without recovery that dirt would
|
|
675
|
+
// permanently wedge every subsequent publish (isOnlyManagedAwCacheDirtiness
|
|
676
|
+
// only self-heals pure `.aw` dirt). Recover instead of hard-failing.
|
|
677
|
+
fmt.logWarn(`AW docs cache had leftover changes; recovering ${cloneDir}`);
|
|
678
|
+
await recoverDirtyDocsCache(cloneDir);
|
|
679
|
+
status = await getGitStatus(cloneDir);
|
|
680
|
+
}
|
|
681
|
+
if (status.trim()) {
|
|
682
|
+
// Still dirty after a hard reset+clean → genuinely unexpected (permissions,
|
|
683
|
+
// index corruption). Surface it rather than push from an unknown state.
|
|
657
684
|
throw new Error([
|
|
658
|
-
`AW docs repo worktree is dirty: ${cloneDir}`,
|
|
685
|
+
`AW docs repo worktree is dirty and could not be auto-recovered: ${cloneDir}`,
|
|
659
686
|
status.trim(),
|
|
660
687
|
'Clean the cached docs repo or set AW_DOCS_WORKTREE to a clean clone before publishing.',
|
|
661
688
|
].join('\n'));
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// integrations/clickup-proxy.mjs — ClickUp client that proxies through the
|
|
2
|
+
// deployed ghl-ai MCP instead of calling api.clickup.com directly.
|
|
3
|
+
//
|
|
4
|
+
// Exposes the same interface as integrations/clickup.mjs
|
|
5
|
+
// ({ listAllTasks, createTask, addComment }) so commands/clickup-sync.mjs can
|
|
6
|
+
// use either client behind one seam. Auth is the caller's GitHub PAT (Bearer);
|
|
7
|
+
// the shared ClickUp token lives server-side, so no CLICKUP_API_TOKEN is needed
|
|
8
|
+
// locally.
|
|
9
|
+
//
|
|
10
|
+
// Retry discipline mirrors the direct client: get-tasks (a read) is retryable;
|
|
11
|
+
// create-task and add-comment (writes) are never auto-retried, so a transport
|
|
12
|
+
// failure after a server-side success cannot duplicate a task or comment. The
|
|
13
|
+
// sync is cross-run resumable via the description sentinel.
|
|
14
|
+
|
|
15
|
+
import { createMcpRpcClient } from './mcp-rpc.mjs';
|
|
16
|
+
|
|
17
|
+
// Matches integrations/clickup.mjs MAX_PAGES — a hard cap so a list that never
|
|
18
|
+
// reports last_page aborts instead of paging forever.
|
|
19
|
+
export const MAX_PAGES = 200;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {{ mcpUrl: string, token: string, rpc?: { callTool: Function } }} opts
|
|
23
|
+
*/
|
|
24
|
+
export function createClickUpProxyClient(opts = {}) {
|
|
25
|
+
const { mcpUrl, token } = opts;
|
|
26
|
+
const rpc = opts.rpc ?? createMcpRpcClient({ url: mcpUrl, token });
|
|
27
|
+
|
|
28
|
+
// Page through every task in a list via the proxy tool. include_closed so
|
|
29
|
+
// completed prior tasks are not recreated; subtasks so nested tasks match too.
|
|
30
|
+
// This is an idempotent read → retryable.
|
|
31
|
+
async function listAllTasks(listId) {
|
|
32
|
+
const tasks = [];
|
|
33
|
+
for (let page = 0; page < MAX_PAGES; page++) {
|
|
34
|
+
const body = await rpc.callTool(
|
|
35
|
+
'clickup_get-tasks',
|
|
36
|
+
{ list_id: listId, page, include_closed: true, subtasks: true },
|
|
37
|
+
{ retryable: true },
|
|
38
|
+
);
|
|
39
|
+
const pageTasks = Array.isArray(body?.tasks) ? body.tasks : [];
|
|
40
|
+
if (pageTasks.length === 0) return tasks;
|
|
41
|
+
tasks.push(...pageTasks);
|
|
42
|
+
if (body?.last_page === true) return tasks;
|
|
43
|
+
}
|
|
44
|
+
throw new Error(`ClickUp list ${listId} exceeded the ${MAX_PAGES}-page cap; aborting to avoid duplicate creates.`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Write — never retried (a retried POST could duplicate the task).
|
|
48
|
+
// tags/priority are create-time enrichment, forwarded only when present so
|
|
49
|
+
// the payload stays identical to the direct client for the same item.
|
|
50
|
+
async function createTask(listId, { name, description, tags, priority }) {
|
|
51
|
+
const args = { list_id: listId, name, description };
|
|
52
|
+
if (tags !== undefined) args.tags = tags;
|
|
53
|
+
if (priority !== undefined) args.priority = priority;
|
|
54
|
+
return rpc.callTool('clickup_create-task', args);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Write — never retried (a retried POST could duplicate the comment).
|
|
58
|
+
async function addComment(taskId, commentText) {
|
|
59
|
+
return rpc.callTool('clickup_add-comment', { task_id: taskId, comment_text: commentText });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return { listAllTasks, createTask, addComment };
|
|
63
|
+
}
|
package/integrations/clickup.mjs
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { resolveGitHubToken, DEFAULT_MCP_URL } from '../mcp.mjs';
|
|
2
|
+
import { createClickUpProxyClient } from './clickup-proxy.mjs';
|
|
3
|
+
|
|
1
4
|
// integrations/clickup.mjs — ClickUp v2 HTTP client for the aw CLI.
|
|
2
5
|
//
|
|
3
6
|
// Faithful .mjs port of apps/api/src/mcp/integrations/clickup.handler.ts, which
|
|
@@ -119,10 +122,15 @@ export function createClickUpClient(opts = {}) {
|
|
|
119
122
|
throw new Error(`ClickUp list ${listId} exceeded the ${MAX_PAGES}-page cap; aborting to avoid duplicate creates.`);
|
|
120
123
|
}
|
|
121
124
|
|
|
122
|
-
async function createTask(listId, { name, description }) {
|
|
125
|
+
async function createTask(listId, { name, description, tags, priority }) {
|
|
126
|
+
// tags/priority are create-time enrichment, included only when present so the
|
|
127
|
+
// body matches the proxy client for the same item (identical outcome invariant).
|
|
128
|
+
const body = { name, description };
|
|
129
|
+
if (tags !== undefined) body.tags = tags;
|
|
130
|
+
if (priority !== undefined) body.priority = priority;
|
|
123
131
|
return clickupFetch(`/list/${encodeURIComponent(listId)}/task`, {
|
|
124
132
|
method: 'POST',
|
|
125
|
-
body: JSON.stringify(
|
|
133
|
+
body: JSON.stringify(body),
|
|
126
134
|
});
|
|
127
135
|
}
|
|
128
136
|
|
|
@@ -135,3 +143,35 @@ export function createClickUpClient(opts = {}) {
|
|
|
135
143
|
|
|
136
144
|
return { clickupFetch, listAllTasks, createTask, addComment };
|
|
137
145
|
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Pick the ClickUp client for `aw clickup-sync`. Proxy-by-default: route through
|
|
149
|
+
* the deployed ghl-ai MCP using the developer's GitHub PAT (no local ClickUp
|
|
150
|
+
* token). `--direct` or AW_CLICKUP_DIRECT=1 opts back into the direct
|
|
151
|
+
* api.clickup.com client (which requires CLICKUP_API_TOKEN).
|
|
152
|
+
*
|
|
153
|
+
* deps are injectable for tests; the CLI passes none.
|
|
154
|
+
* @param {Record<string, unknown>} [args] parsed CLI args
|
|
155
|
+
* @param {{ env?: NodeJS.ProcessEnv, resolveGitHubToken?: Function,
|
|
156
|
+
* createClickUpClient?: Function, createClickUpProxyClient?: Function }} [deps]
|
|
157
|
+
*/
|
|
158
|
+
export function resolveClickUpClient(args = {}, deps = {}) {
|
|
159
|
+
const env = deps.env ?? process.env;
|
|
160
|
+
const resolveToken = deps.resolveGitHubToken ?? resolveGitHubToken;
|
|
161
|
+
const makeDirect = deps.createClickUpClient ?? (() => createClickUpClient());
|
|
162
|
+
const makeProxy = deps.createClickUpProxyClient ?? ((cfg) => createClickUpProxyClient(cfg));
|
|
163
|
+
|
|
164
|
+
const wantsDirect = args['--direct'] === true || env.AW_CLICKUP_DIRECT === '1';
|
|
165
|
+
if (wantsDirect) {
|
|
166
|
+
return makeDirect();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const token = resolveToken(true);
|
|
170
|
+
if (!token) {
|
|
171
|
+
throw new Error(
|
|
172
|
+
'No GitHub PAT for the ghl-ai MCP proxy. Run `gh auth login` or set GITHUB_TOKEN, or use --direct with CLICKUP_API_TOKEN.',
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
const mcpUrl = env.GHL_MCP_URL || DEFAULT_MCP_URL;
|
|
176
|
+
return makeProxy({ mcpUrl, token });
|
|
177
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// integrations/mcp-rpc.mjs — minimal MCP JSON-RPC session client for the aw CLI.
|
|
2
|
+
//
|
|
3
|
+
// A standalone-ESM port of the subset of apps/api/src/mcp/integrations/
|
|
4
|
+
// remote-mcp.client.ts that the CLI needs: open a session (initialize ->
|
|
5
|
+
// notifications/initialized), then call tools (tools/call) over Streamable
|
|
6
|
+
// HTTP, carrying Mcp-Session-Id and unwrapping the MCP content envelope.
|
|
7
|
+
//
|
|
8
|
+
// Retry policy is the load-bearing safety property: callTool does NOT retry by
|
|
9
|
+
// default. Only reads that pass { retryable: true } are retried on transport
|
|
10
|
+
// timeouts/5xx. Writes (create-task, add-comment) must never auto-retry — a
|
|
11
|
+
// transport failure after a server-side success would duplicate the write; the
|
|
12
|
+
// caller is expected to be resumable instead.
|
|
13
|
+
|
|
14
|
+
const DEFAULT_PROTOCOL_VERSION = '2025-03-26';
|
|
15
|
+
const DEFAULT_CLIENT_INFO = { name: 'ghl-ai-aw-cli', version: '1.0.0' };
|
|
16
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
17
|
+
|
|
18
|
+
export class McpRpcError extends Error {
|
|
19
|
+
constructor(message, { status, timeout = false, transport = false } = {}) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.name = 'McpRpcError';
|
|
22
|
+
this.status = status;
|
|
23
|
+
this.timeout = timeout;
|
|
24
|
+
this.transport = transport;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function errMessage(err) {
|
|
29
|
+
return err instanceof Error ? err.message : String(err);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Parse an MCP HTTP body as either a JSON-RPC object or the last data: frame of
|
|
33
|
+
// an SSE stream (Streamable HTTP fallback). Mirrors remote-mcp.client parseRpcPayload.
|
|
34
|
+
function parseRpcPayload(raw) {
|
|
35
|
+
const trimmed = (raw ?? '').trim();
|
|
36
|
+
if (!trimmed) return {};
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
return JSON.parse(trimmed);
|
|
40
|
+
} catch {
|
|
41
|
+
// Fall through to SSE frame parsing.
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const lines = trimmed.split(/\r?\n/);
|
|
45
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
46
|
+
const line = lines[i]?.trim();
|
|
47
|
+
if (!line?.startsWith('data:')) continue;
|
|
48
|
+
const payload = line.slice(5).trim();
|
|
49
|
+
if (!payload || payload === '[DONE]') continue;
|
|
50
|
+
try {
|
|
51
|
+
return JSON.parse(payload);
|
|
52
|
+
} catch {
|
|
53
|
+
// Try an earlier frame.
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
throw new McpRpcError(`Unable to parse upstream MCP response: ${trimmed.slice(0, 300)}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Unwrap an MCP tools/call `result` content envelope into a usable value.
|
|
61
|
+
// A single text block is JSON-parsed (falling back to the raw text); an error
|
|
62
|
+
// envelope throws. Mirrors remote-mcp.client unwrapMcpTextContent.
|
|
63
|
+
export function unwrapMcpContent(payload) {
|
|
64
|
+
if (!payload || typeof payload !== 'object') return payload;
|
|
65
|
+
|
|
66
|
+
const result = payload;
|
|
67
|
+
if (result.isError === true) {
|
|
68
|
+
const errorText = Array.isArray(result.content)
|
|
69
|
+
? result.content.find((item) => item?.type === 'text')?.text
|
|
70
|
+
: undefined;
|
|
71
|
+
throw new McpRpcError(typeof errorText === 'string' ? errorText : 'Upstream MCP tool returned an error');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!Array.isArray(result.content) || result.content.length === 0) {
|
|
75
|
+
return payload;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (result.content.length === 1 && result.content[0]?.type === 'text') {
|
|
79
|
+
const text = result.content[0]?.text;
|
|
80
|
+
if (typeof text !== 'string') return payload;
|
|
81
|
+
try {
|
|
82
|
+
return JSON.parse(text);
|
|
83
|
+
} catch {
|
|
84
|
+
return text;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return result.content;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function isStaleSessionError(err) {
|
|
92
|
+
if (!(err instanceof McpRpcError)) return false;
|
|
93
|
+
if (err.status === 404 || err.status === 410) return true;
|
|
94
|
+
const body = errMessage(err).toLowerCase();
|
|
95
|
+
return body.includes('session') && (body.includes('not found') || body.includes('expired') || body.includes('invalid'));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function isRetryableError(err) {
|
|
99
|
+
if (err instanceof McpRpcError) {
|
|
100
|
+
if (err.timeout || err.transport) return true;
|
|
101
|
+
return err.status === 502 || err.status === 503 || err.status === 504;
|
|
102
|
+
}
|
|
103
|
+
return err instanceof Error;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* @param {{ url: string, token: string, fetchImpl?: typeof fetch, timeoutMs?: number,
|
|
108
|
+
* clientInfo?: { name: string, version: string }, protocolVersion?: string }} config
|
|
109
|
+
*/
|
|
110
|
+
export function createMcpRpcClient(config = {}) {
|
|
111
|
+
const { url, token } = config;
|
|
112
|
+
if (!url || typeof url !== 'string') throw new Error('createMcpRpcClient: url is required');
|
|
113
|
+
if (!token || typeof token !== 'string') throw new Error('createMcpRpcClient: token is required');
|
|
114
|
+
|
|
115
|
+
const doFetch = config.fetchImpl ?? globalThis.fetch;
|
|
116
|
+
if (typeof doFetch !== 'function') throw new Error('createMcpRpcClient: fetch is unavailable');
|
|
117
|
+
const timeoutMs = Number.isFinite(config.timeoutMs) && config.timeoutMs > 0 ? config.timeoutMs : DEFAULT_TIMEOUT_MS;
|
|
118
|
+
const clientInfo = config.clientInfo ?? DEFAULT_CLIENT_INFO;
|
|
119
|
+
const protocolVersion = config.protocolVersion ?? DEFAULT_PROTOCOL_VERSION;
|
|
120
|
+
|
|
121
|
+
let sessionId;
|
|
122
|
+
let initialized = false;
|
|
123
|
+
let rpcId = 1;
|
|
124
|
+
|
|
125
|
+
async function sendRpc(payload, { parseBody = true } = {}) {
|
|
126
|
+
const controller = new AbortController();
|
|
127
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
128
|
+
let response;
|
|
129
|
+
try {
|
|
130
|
+
response = await doFetch(url, {
|
|
131
|
+
method: 'POST',
|
|
132
|
+
headers: {
|
|
133
|
+
Accept: 'application/json, text/event-stream',
|
|
134
|
+
'Content-Type': 'application/json',
|
|
135
|
+
Authorization: `Bearer ${token}`,
|
|
136
|
+
...(sessionId ? { 'Mcp-Session-Id': sessionId } : {}),
|
|
137
|
+
},
|
|
138
|
+
body: JSON.stringify(payload),
|
|
139
|
+
signal: controller.signal,
|
|
140
|
+
});
|
|
141
|
+
} catch (err) {
|
|
142
|
+
if (err instanceof Error && err.name === 'AbortError') {
|
|
143
|
+
throw new McpRpcError(`MCP request timed out after ${timeoutMs}ms`, { timeout: true });
|
|
144
|
+
}
|
|
145
|
+
throw new McpRpcError(`MCP endpoint unreachable: ${errMessage(err)}`, { transport: true });
|
|
146
|
+
} finally {
|
|
147
|
+
clearTimeout(timer);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const raw = parseBody ? await response.text() : '';
|
|
151
|
+
const sid = response.headers.get('mcp-session-id');
|
|
152
|
+
if (sid) sessionId = sid;
|
|
153
|
+
|
|
154
|
+
if (!response.ok) {
|
|
155
|
+
throw new McpRpcError(`MCP ${response.status}: ${raw.slice(0, 500)}`, { status: response.status });
|
|
156
|
+
}
|
|
157
|
+
return parseBody ? parseRpcPayload(raw) : undefined;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function invalidateSession() {
|
|
161
|
+
sessionId = undefined;
|
|
162
|
+
initialized = false;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function ensureSession() {
|
|
166
|
+
if (initialized && sessionId) return;
|
|
167
|
+
await sendRpc({
|
|
168
|
+
jsonrpc: '2.0',
|
|
169
|
+
id: rpcId++,
|
|
170
|
+
method: 'initialize',
|
|
171
|
+
params: { protocolVersion, capabilities: {}, clientInfo },
|
|
172
|
+
});
|
|
173
|
+
await sendRpc({ jsonrpc: '2.0', method: 'notifications/initialized', params: {} }, { parseBody: false });
|
|
174
|
+
initialized = true;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function unwrapEnvelope(payload) {
|
|
178
|
+
if (!payload || typeof payload !== 'object') return payload;
|
|
179
|
+
if (payload.error) {
|
|
180
|
+
throw new McpRpcError(payload.error.message ?? 'MCP returned an error');
|
|
181
|
+
}
|
|
182
|
+
return 'result' in payload ? payload.result : payload;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Call an MCP tool and return its unwrapped content.
|
|
187
|
+
* @param {string} name
|
|
188
|
+
* @param {Record<string, unknown>} args
|
|
189
|
+
* @param {{ retryable?: boolean }} [options] retryable=true only for idempotent reads.
|
|
190
|
+
*/
|
|
191
|
+
async function callTool(name, args = {}, { retryable = false } = {}) {
|
|
192
|
+
const invoke = async () => {
|
|
193
|
+
await ensureSession();
|
|
194
|
+
const parsed = await sendRpc({
|
|
195
|
+
jsonrpc: '2.0',
|
|
196
|
+
id: rpcId++,
|
|
197
|
+
method: 'tools/call',
|
|
198
|
+
params: { name, arguments: args },
|
|
199
|
+
});
|
|
200
|
+
return unwrapMcpContent(unwrapEnvelope(parsed));
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
try {
|
|
204
|
+
return await invoke();
|
|
205
|
+
} catch (err) {
|
|
206
|
+
if (isStaleSessionError(err)) {
|
|
207
|
+
invalidateSession();
|
|
208
|
+
return invoke();
|
|
209
|
+
}
|
|
210
|
+
if (retryable && isRetryableError(err)) {
|
|
211
|
+
return invoke();
|
|
212
|
+
}
|
|
213
|
+
throw err;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return { callTool };
|
|
218
|
+
}
|
package/mcp.mjs
CHANGED
|
@@ -11,7 +11,7 @@ import * as p from '@clack/prompts';
|
|
|
11
11
|
import * as fmt from './fmt.mjs';
|
|
12
12
|
|
|
13
13
|
const HOME = homedir();
|
|
14
|
-
const DEFAULT_MCP_URL = 'https://services.leadconnectorhq.com/agentic-workspace/mcp';
|
|
14
|
+
export const DEFAULT_MCP_URL = 'https://services.leadconnectorhq.com/agentic-workspace/mcp';
|
|
15
15
|
const MCP_PREFS_FILENAME = 'mcp-preferences.json';
|
|
16
16
|
const ENABLED_MODE = 'enabled';
|
|
17
17
|
const DISABLED_MODE = 'disabled';
|
|
@@ -121,7 +121,7 @@ function warnIfCodexBearerEnvMissing(envVar, silent = false) {
|
|
|
121
121
|
* 2. `gh auth token` (GitHub CLI — most devs have this)
|
|
122
122
|
* 3. null (fall back to ${GITHUB_TOKEN} interpolation in config)
|
|
123
123
|
*/
|
|
124
|
-
function resolveGitHubToken(silent = false) {
|
|
124
|
+
export function resolveGitHubToken(silent = false) {
|
|
125
125
|
// 1. Environment variable
|
|
126
126
|
if (process.env.GITHUB_TOKEN) {
|
|
127
127
|
if (!silent) fmt.logStep('Using GITHUB_TOKEN from environment');
|