@meetopenbot/linear 0.0.2 → 0.0.4
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 +17 -60
- package/dist/cloud-mode.js +10 -0
- package/dist/config.js +26 -107
- package/dist/credits-auth.js +53 -0
- package/dist/index.js +60 -261
- package/dist/linear-agent.js +142 -61
- package/dist/linear-mcp.js +9 -21
- package/dist/mcp-errors.js +40 -0
- package/dist/mcp-tool-args.js +38 -0
- package/dist/model-registry.js +40 -0
- package/dist/model.js +24 -0
- package/dist/thread-context.js +72 -0
- package/package.json +12 -23
- package/dist/api.d.ts +0 -17
- package/dist/api.js +0 -29
- package/dist/auth.d.ts +0 -33
- package/dist/auth.js +0 -91
- package/dist/config.d.ts +0 -39
- package/dist/index.d.ts +0 -53
- package/dist/linear-agent.d.ts +0 -14
- package/dist/linear-issues.d.ts +0 -33
- package/dist/linear-issues.js +0 -136
- package/dist/linear-mcp.d.ts +0 -4
- package/dist/oauth-pending.d.ts +0 -13
- package/dist/oauth-pending.js +0 -40
- package/dist/oauth.d.ts +0 -95
- package/dist/oauth.js +0 -372
- package/dist/tools.d.ts +0 -17
- package/dist/tools.js +0 -290
- package/src/config.ts +0 -228
- package/src/index.ts +0 -449
- package/src/linear-agent.ts +0 -107
- package/src/linear-issues.ts +0 -188
- package/src/linear-mcp.ts +0 -33
- package/src/oauth-pending.ts +0 -68
- package/src/oauth.ts +0 -563
package/dist/index.js
CHANGED
|
@@ -1,250 +1,42 @@
|
|
|
1
|
-
import { agentOutput, definePlugin, shouldHandleInvoke,
|
|
2
|
-
import {
|
|
1
|
+
import { agentOutput, definePlugin, shouldHandleInvoke, uiWidget, } from "@meetopenbot/plugin-sdk";
|
|
2
|
+
import { isCloudMode } from "./cloud-mode.js";
|
|
3
|
+
import { CREDITS_NOT_CONFIGURED_MESSAGE, creditsErrorMessage, resolveCreditsAuthConfig, } from "./credits-auth.js";
|
|
4
|
+
import { formatMissingCredentials, readLinearConfig, resolveLinearCredentials, } from "./config.js";
|
|
5
|
+
import { resolveModelConfigField } from "./model-registry.js";
|
|
3
6
|
import { runLinearAgent } from "./linear-agent.js";
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
/** Client-side action handled by openbot.one — opens `value.url` in the browser. */
|
|
7
|
-
const OPEN_URL_ACTION_ID = "open_url";
|
|
8
|
-
const CONNECT_TOOL = {
|
|
9
|
-
description: "Connect the workspace to Linear via OAuth. Returns an authorization link the user must open in their browser; after they approve, the token is stored automatically. Use this when Linear is not connected yet or credentials expired.",
|
|
10
|
-
inputSchema: { type: "object", properties: {} },
|
|
11
|
-
};
|
|
12
|
-
const DISCONNECT_TOOL = {
|
|
13
|
-
description: "Disconnect Linear: removes the stored OAuth tokens from the workspace.",
|
|
14
|
-
inputSchema: { type: "object", properties: {} },
|
|
15
|
-
};
|
|
16
|
-
const CONNECT_WAIT_MS = 120 * 1000;
|
|
17
|
-
const CONNECT_WIDGET_ID = "linear-connect";
|
|
18
|
-
const CONNECT_PROMPT_WIDGET_ID = "linear-connect-prompt";
|
|
19
|
-
const toolDefinitions = {
|
|
20
|
-
linear_connect: CONNECT_TOOL,
|
|
21
|
-
linear_disconnect: DISCONNECT_TOOL,
|
|
22
|
-
};
|
|
23
|
-
function* emitConnectReply(mode, context, event, data) {
|
|
24
|
-
if (mode === "tool") {
|
|
25
|
-
yield toolResult("linear_connect", event, data);
|
|
26
|
-
return;
|
|
27
|
-
}
|
|
28
|
-
yield agentOutput({
|
|
29
|
-
agentId: context.agentId,
|
|
30
|
-
content: data.output,
|
|
31
|
-
threadId: event.meta?.threadId,
|
|
32
|
-
meta: event.meta,
|
|
33
|
-
});
|
|
34
|
-
}
|
|
35
|
-
function* yieldOAuthAuthorizeWidget(agentId, authorizeUrl, modeHint, options) {
|
|
36
|
-
yield uiWidget({
|
|
37
|
-
agentId,
|
|
38
|
-
threadId: options.threadId,
|
|
39
|
-
meta: options.meta,
|
|
40
|
-
widget: {
|
|
41
|
-
kind: "message",
|
|
42
|
-
widgetId: options.widgetId ?? CONNECT_WIDGET_ID,
|
|
43
|
-
title: "Connect Linear",
|
|
44
|
-
body: `Open Linear in your browser to authorize OpenBot. ${modeHint}`,
|
|
45
|
-
actions: [
|
|
46
|
-
{
|
|
47
|
-
id: OPEN_URL_ACTION_ID,
|
|
48
|
-
label: "Connect Linear",
|
|
49
|
-
variant: "primary",
|
|
50
|
-
value: { url: authorizeUrl },
|
|
51
|
-
},
|
|
52
|
-
],
|
|
53
|
-
},
|
|
54
|
-
});
|
|
55
|
-
}
|
|
56
|
-
async function createOAuthHandle(context, publicBaseUrl) {
|
|
57
|
-
const config = readLinearConfig(context.config ?? {});
|
|
58
|
-
const webhookBaseUrl = resolveWebhookBaseUrl(config, publicBaseUrl);
|
|
59
|
-
if (config.apiKey || !config.clientId)
|
|
60
|
-
return null;
|
|
61
|
-
const scopes = config.scopes ?? "read,write,issues:create,comments:create";
|
|
62
|
-
const handle = webhookBaseUrl
|
|
63
|
-
? await startWebhookOAuthFlow({
|
|
64
|
-
storage: context.storage,
|
|
65
|
-
clientId: config.clientId,
|
|
66
|
-
clientSecret: config.clientSecret,
|
|
67
|
-
scopes,
|
|
68
|
-
webhookBaseUrl,
|
|
69
|
-
})
|
|
70
|
-
: startOAuthFlow({
|
|
71
|
-
clientId: config.clientId,
|
|
72
|
-
clientSecret: config.clientSecret,
|
|
73
|
-
port: config.oauthPort ?? 4137,
|
|
74
|
-
scopes,
|
|
75
|
-
onSuccess: (tokens) => saveTokens(context.storage, tokens),
|
|
76
|
-
});
|
|
77
|
-
const modeHint = webhookBaseUrl
|
|
78
|
-
? `After approval, Linear redirects to \`${handle.redirectUri}\`.`
|
|
79
|
-
: "After you approve access you'll be redirected back and can return here.";
|
|
80
|
-
return { handle, modeHint };
|
|
81
|
-
}
|
|
82
|
-
function notConnectedMessage(missing) {
|
|
83
|
-
const needsConnect = missing.includes("accessToken");
|
|
84
|
-
const needsOpenAi = missing.includes("openaiApiKey");
|
|
85
|
-
if (needsConnect && needsOpenAi) {
|
|
86
|
-
return "Linear isn't connected yet, and the OpenAI API key is missing. Connect Linear below, then add `openaiApiKey` to the plugin config.";
|
|
87
|
-
}
|
|
88
|
-
if (needsConnect) {
|
|
89
|
-
return "Linear isn't connected yet. Click **Connect Linear** below to open the authorization page.";
|
|
90
|
-
}
|
|
91
|
-
return formatMissingCredentials(missing);
|
|
92
|
-
}
|
|
93
|
-
async function* handleConnect(context, event, publicBaseUrl, replyMode = "tool") {
|
|
94
|
-
const config = readLinearConfig(context.config ?? {});
|
|
95
|
-
const threadId = event.meta?.threadId;
|
|
96
|
-
const webhookBaseUrl = resolveWebhookBaseUrl(config, publicBaseUrl);
|
|
97
|
-
if (config.apiKey) {
|
|
98
|
-
yield* emitConnectReply(replyMode, context, event, {
|
|
99
|
-
output: "Linear is already configured with an API key in the plugin config — no OAuth connection needed.",
|
|
100
|
-
});
|
|
101
|
-
return;
|
|
102
|
-
}
|
|
103
|
-
const oauth = await createOAuthHandle(context, publicBaseUrl);
|
|
104
|
-
if (!oauth) {
|
|
105
|
-
const webhookHint = webhookBaseUrl
|
|
106
|
-
? ` set its callback URL to ${buildOAuthRedirectUri(webhookBaseUrl)},`
|
|
107
|
-
: ` set its callback URL to http://localhost:${config.oauthPort}/oauth/callback (local), or configure webhookBaseUrl for ${buildOAuthRedirectUri("https://<your-host>")},`;
|
|
108
|
-
yield* emitConnectReply(replyMode, context, event, {
|
|
109
|
-
output: [
|
|
110
|
-
"Linear OAuth is not configured. Ways to connect:",
|
|
111
|
-
"1. Recommended: open the OpenBot web app → Settings → Plugins and click Connect on the Linear plugin (install-time OAuth, no setup needed).",
|
|
112
|
-
"2. Self-hosted OAuth: create an OAuth application at https://linear.app/settings/api/applications,",
|
|
113
|
-
webhookHint,
|
|
114
|
-
" then put its Client ID into this plugin's `clientId` config field and run linear_connect again.",
|
|
115
|
-
"3. API key: create a personal API key at https://linear.app/settings/api and put it into the `apiKey` config field (or a LINEAR_API_KEY secret variable).",
|
|
116
|
-
].join("\n"),
|
|
117
|
-
});
|
|
118
|
-
return;
|
|
119
|
-
}
|
|
120
|
-
const { handle, modeHint } = oauth;
|
|
121
|
-
yield* yieldOAuthAuthorizeWidget(context.agentId, handle.authorizeUrl, modeHint, { threadId, meta: event.meta });
|
|
122
|
-
const outcome = await Promise.race([
|
|
123
|
-
handle.completion,
|
|
124
|
-
new Promise((resolve) => {
|
|
125
|
-
const t = setTimeout(() => resolve("pending"), CONNECT_WAIT_MS);
|
|
126
|
-
t.unref?.();
|
|
127
|
-
}),
|
|
128
|
-
]);
|
|
129
|
-
if (outcome === "pending") {
|
|
130
|
-
yield* emitConnectReply(replyMode, context, event, {
|
|
131
|
-
authorizeUrl: handle.authorizeUrl,
|
|
132
|
-
redirectUri: handle.redirectUri,
|
|
133
|
-
output: "Authorization link sent. Waiting for the user to approve in the browser — the link stays valid for 10 minutes. Once they confirm, ask me to work with Linear again.",
|
|
134
|
-
});
|
|
135
|
-
return;
|
|
136
|
-
}
|
|
137
|
-
if (!outcome) {
|
|
138
|
-
yield* emitConnectReply(replyMode, context, event, {
|
|
139
|
-
error: "authorization_failed",
|
|
140
|
-
output: "Linear authorization did not complete (denied, failed, or the callback is unavailable). Run linear_connect to try again.",
|
|
141
|
-
});
|
|
142
|
-
return;
|
|
143
|
-
}
|
|
144
|
-
let who = "";
|
|
145
|
-
try {
|
|
146
|
-
const { viewer, organization } = await fetchViewer(outcome.accessToken);
|
|
147
|
-
who = ` as ${viewer.displayName ?? viewer.name} in workspace "${organization.name}"`;
|
|
148
|
-
}
|
|
149
|
-
catch {
|
|
150
|
-
// Connection succeeded even if the identity lookup failed.
|
|
151
|
-
}
|
|
152
|
-
yield uiWidget({
|
|
153
|
-
agentId: context.agentId,
|
|
154
|
-
threadId,
|
|
155
|
-
widget: {
|
|
156
|
-
kind: "message",
|
|
157
|
-
widgetId: CONNECT_WIDGET_ID,
|
|
158
|
-
title: "Linear connected",
|
|
159
|
-
body: `✅ Connected to Linear${who}.`,
|
|
160
|
-
state: "submitted",
|
|
161
|
-
},
|
|
162
|
-
});
|
|
163
|
-
yield* emitConnectReply(replyMode, context, event, {
|
|
164
|
-
connected: true,
|
|
165
|
-
output: `Successfully connected to Linear${who}. Tokens are stored securely and refresh automatically.`,
|
|
166
|
-
});
|
|
167
|
-
}
|
|
7
|
+
import { loadThreadContext } from "./thread-context.js";
|
|
8
|
+
const modelField = await resolveModelConfigField();
|
|
168
9
|
const linearPluginConfigSchema = {
|
|
169
10
|
type: "object",
|
|
170
11
|
properties: {
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
12
|
+
...(isCloudMode()
|
|
13
|
+
? {
|
|
14
|
+
authMode: {
|
|
15
|
+
type: "string",
|
|
16
|
+
description: "Credits uses your workspace credit balance via OpenBot. BYOK uses `OPENAI_API_KEY` from workspace settings.",
|
|
17
|
+
enum: ["credits", "byok"],
|
|
18
|
+
default: "credits",
|
|
19
|
+
},
|
|
20
|
+
}
|
|
21
|
+
: {}),
|
|
22
|
+
model: {
|
|
23
|
+
...modelField,
|
|
24
|
+
description: "OpenAI model for direct Linear agent invocations",
|
|
179
25
|
},
|
|
180
26
|
apiKey: {
|
|
181
27
|
type: "string",
|
|
182
|
-
description: "Linear personal API key (
|
|
28
|
+
description: "Linear personal API key (Settings → Account → Security & Access → Personal API keys)",
|
|
183
29
|
format: "password",
|
|
184
30
|
},
|
|
185
|
-
oauthPort: {
|
|
186
|
-
type: "number",
|
|
187
|
-
description: "Local port for the OAuth callback (must match the OAuth app callback URL)",
|
|
188
|
-
default: 4137,
|
|
189
|
-
},
|
|
190
|
-
scopes: {
|
|
191
|
-
type: "string",
|
|
192
|
-
description: "Comma-separated OAuth scopes",
|
|
193
|
-
default: "read,write,issues:create,comments:create",
|
|
194
|
-
},
|
|
195
|
-
webhookBaseUrl: {
|
|
196
|
-
type: "string",
|
|
197
|
-
description: "Public runtime base URL for OAuth callback via /api/webhooks/linear (e.g. https://my-host.com). Falls back to host publicBaseUrl when omitted.",
|
|
198
|
-
format: "url",
|
|
199
|
-
},
|
|
200
|
-
openaiApiKey: {
|
|
201
|
-
type: "string",
|
|
202
|
-
description: "OpenAI API key for direct Linear agent invocations",
|
|
203
|
-
format: "password",
|
|
204
|
-
},
|
|
205
|
-
model: {
|
|
206
|
-
type: "string",
|
|
207
|
-
description: "OpenAI model id for direct Linear agent invocations (default: gpt-4o-mini)",
|
|
208
|
-
default: "gpt-4o-mini",
|
|
209
|
-
},
|
|
210
31
|
},
|
|
211
32
|
};
|
|
212
33
|
export default definePlugin({
|
|
213
|
-
id: "linear",
|
|
214
34
|
name: "Linear",
|
|
215
|
-
description: "Linear
|
|
35
|
+
description: "Linear MCP-backed specialist agent for issues, projects, and comments",
|
|
216
36
|
configSchema: linearPluginConfigSchema,
|
|
217
|
-
toolDefinitions,
|
|
218
37
|
factory: (pluginContext) => (builder) => {
|
|
219
38
|
const { agentId, config, storage } = pluginContext;
|
|
220
39
|
const linearConfig = readLinearConfig(config);
|
|
221
|
-
const publicBaseUrl = pluginContext.publicBaseUrl ?? "";
|
|
222
|
-
builder.on("action:webhook", async function* (event) {
|
|
223
|
-
const webhook = event;
|
|
224
|
-
if (webhook.data.provider !== OAUTH_WEBHOOK_PROVIDER)
|
|
225
|
-
return;
|
|
226
|
-
const result = await handleWebhookOAuthCallback({
|
|
227
|
-
storage,
|
|
228
|
-
query: webhook.data.query ?? {},
|
|
229
|
-
onSuccess: (tokens) => saveTokens(storage, tokens),
|
|
230
|
-
});
|
|
231
|
-
if (result.kind === "ignore")
|
|
232
|
-
return;
|
|
233
|
-
yield webhookHttpResponse({
|
|
234
|
-
status: result.status,
|
|
235
|
-
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
236
|
-
body: result.html,
|
|
237
|
-
});
|
|
238
|
-
});
|
|
239
|
-
builder.on("action:linear_connect", async function* (event) {
|
|
240
|
-
yield* handleConnect(pluginContext, event, publicBaseUrl);
|
|
241
|
-
});
|
|
242
|
-
builder.on("action:linear_disconnect", async function* (event) {
|
|
243
|
-
await clearTokens(storage);
|
|
244
|
-
yield toolResult("linear_disconnect", event, {
|
|
245
|
-
output: "Linear disconnected — stored OAuth tokens were removed.",
|
|
246
|
-
});
|
|
247
|
-
});
|
|
248
40
|
builder.on("agent:invoke", async function* (event, ctx) {
|
|
249
41
|
if (!shouldHandleInvoke(event, agentId))
|
|
250
42
|
return;
|
|
@@ -259,56 +51,63 @@ export default definePlugin({
|
|
|
259
51
|
if (!auth.ok) {
|
|
260
52
|
yield agentOutput({
|
|
261
53
|
agentId,
|
|
262
|
-
content:
|
|
54
|
+
content: formatMissingCredentials(auth.missing, linearConfig.authMode),
|
|
55
|
+
threadId,
|
|
56
|
+
meta: event.meta,
|
|
57
|
+
});
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (auth.credentials.authMode === "credits" &&
|
|
61
|
+
!resolveCreditsAuthConfig()) {
|
|
62
|
+
yield agentOutput({
|
|
63
|
+
agentId,
|
|
64
|
+
content: CREDITS_NOT_CONFIGURED_MESSAGE,
|
|
263
65
|
threadId,
|
|
264
66
|
meta: event.meta,
|
|
265
67
|
});
|
|
266
|
-
if (auth.missing.includes("accessToken")) {
|
|
267
|
-
const oauth = await createOAuthHandle(pluginContext, publicBaseUrl);
|
|
268
|
-
if (oauth) {
|
|
269
|
-
yield* yieldOAuthAuthorizeWidget(agentId, oauth.handle.authorizeUrl, oauth.modeHint, {
|
|
270
|
-
threadId,
|
|
271
|
-
meta: event.meta,
|
|
272
|
-
widgetId: CONNECT_PROMPT_WIDGET_ID,
|
|
273
|
-
});
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
68
|
return;
|
|
277
69
|
}
|
|
278
70
|
try {
|
|
279
|
-
const
|
|
71
|
+
const threadContext = await loadThreadContext(storage, {
|
|
72
|
+
channelId: ctx.state.channelId,
|
|
73
|
+
threadId,
|
|
74
|
+
agentId,
|
|
75
|
+
currentMessage: userMessage,
|
|
76
|
+
});
|
|
77
|
+
for await (const chunk of runLinearAgent({
|
|
280
78
|
prompt: userMessage,
|
|
79
|
+
threadContext: threadContext || undefined,
|
|
80
|
+
authMode: auth.credentials.authMode,
|
|
281
81
|
openaiApiKey: auth.credentials.openaiApiKey,
|
|
282
|
-
|
|
82
|
+
apiKey: auth.credentials.apiKey,
|
|
283
83
|
model: auth.credentials.model,
|
|
284
|
-
})
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
84
|
+
})) {
|
|
85
|
+
if (chunk.kind === "widget") {
|
|
86
|
+
yield uiWidget({
|
|
87
|
+
agentId,
|
|
88
|
+
widget: chunk.widget,
|
|
89
|
+
threadId,
|
|
90
|
+
meta: event.meta,
|
|
91
|
+
});
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
yield agentOutput({
|
|
289
95
|
agentId,
|
|
96
|
+
content: chunk.content,
|
|
290
97
|
threadId,
|
|
291
98
|
meta: event.meta,
|
|
292
|
-
widget: buildIssuesListWidget(issues, {
|
|
293
|
-
title: issuesListTitle(userMessage),
|
|
294
|
-
}),
|
|
295
99
|
});
|
|
296
100
|
}
|
|
297
|
-
const content = reply.toolErrors.length > 0 && !reply.usedTools
|
|
298
|
-
? `${reply.text}\n\nMCP note: ${reply.toolErrors.join("; ")}`
|
|
299
|
-
: reply.text;
|
|
300
|
-
yield agentOutput({
|
|
301
|
-
agentId,
|
|
302
|
-
content,
|
|
303
|
-
threadId,
|
|
304
|
-
meta: event.meta,
|
|
305
|
-
});
|
|
306
101
|
}
|
|
307
102
|
catch (error) {
|
|
308
103
|
const message = error instanceof Error ? error.message : String(error);
|
|
104
|
+
const creditsMessage = auth.credentials.authMode === "credits"
|
|
105
|
+
? creditsErrorMessage(message)
|
|
106
|
+
: undefined;
|
|
309
107
|
yield agentOutput({
|
|
310
108
|
agentId,
|
|
311
|
-
content:
|
|
109
|
+
content: (creditsMessage ?? message) ||
|
|
110
|
+
"Linear agent failed for an unknown reason. Please try again.",
|
|
312
111
|
threadId,
|
|
313
112
|
meta: event.meta,
|
|
314
113
|
});
|
package/dist/linear-agent.js
CHANGED
|
@@ -1,72 +1,153 @@
|
|
|
1
|
-
import { createOpenAI } from "@ai-sdk/openai";
|
|
2
1
|
import { generateText, stepCountIs } from "ai";
|
|
3
|
-
import { extractIssuesFromToolResults } from "./linear-issues.js";
|
|
4
2
|
import { createLinearMcpClient } from "./linear-mcp.js";
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const record = output;
|
|
17
|
-
if (typeof record.error === "string") {
|
|
18
|
-
errors.push(`${toolResult.toolName}: ${record.error}`);
|
|
19
|
-
continue;
|
|
20
|
-
}
|
|
21
|
-
if (typeof record.isError === "boolean" && record.isError) {
|
|
22
|
-
const text = typeof record.text === "string"
|
|
23
|
-
? record.text
|
|
24
|
-
: JSON.stringify(record).slice(0, 300);
|
|
25
|
-
errors.push(`${toolResult.toolName}: ${text}`);
|
|
26
|
-
}
|
|
3
|
+
import { throwMcpError } from "./mcp-errors.js";
|
|
4
|
+
import { wrapMcpTools } from "./mcp-tool-args.js";
|
|
5
|
+
import { resolveOpenAiModel } from "./model.js";
|
|
6
|
+
const TOOL_OUTPUT_MAX_LENGTH = 2_000;
|
|
7
|
+
function formatToolOutput(output) {
|
|
8
|
+
if (output === undefined || output === null)
|
|
9
|
+
return "Done.";
|
|
10
|
+
if (typeof output === "string")
|
|
11
|
+
return truncate(output);
|
|
12
|
+
try {
|
|
13
|
+
return truncate(JSON.stringify(output, null, 2));
|
|
27
14
|
}
|
|
28
|
-
|
|
15
|
+
catch {
|
|
16
|
+
return truncate(String(output));
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function truncate(text) {
|
|
20
|
+
if (text.length <= TOOL_OUTPUT_MAX_LENGTH)
|
|
21
|
+
return text;
|
|
22
|
+
return `${text.slice(0, TOOL_OUTPUT_MAX_LENGTH)}…`;
|
|
23
|
+
}
|
|
24
|
+
function toolCallWidget(args) {
|
|
25
|
+
return {
|
|
26
|
+
kind: "message",
|
|
27
|
+
variant: "basic",
|
|
28
|
+
display: "collapsed",
|
|
29
|
+
widgetId: args.widgetId,
|
|
30
|
+
title: args.title,
|
|
31
|
+
...(args.body ? { body: args.body } : {}),
|
|
32
|
+
};
|
|
29
33
|
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
+
const SYSTEM_PROMPT = `You are the OpenBot Linear specialist agent. Help users search, read, create, and update Linear issues, projects, teams, comments, and documents.
|
|
35
|
+
|
|
36
|
+
## Ground rules
|
|
37
|
+
- Use Linear MCP tools for all workspace facts. Never invent issue IDs, titles, statuses, assignees, URLs, or counts.
|
|
38
|
+
- Authentication is already configured. Do not call auth tools.
|
|
39
|
+
- Use prior conversation context when provided. Treat the latest user message as the current request.
|
|
40
|
+
- If the request is ambiguous (missing team, assignee, or required fields), ask a short clarifying question instead of guessing.
|
|
41
|
+
- Omit optional tool parameters when unused. Never pass empty strings or empty arrays for optional filters or pagination.
|
|
42
|
+
- Confirm before destructive actions unless the user was explicit.
|
|
43
|
+
|
|
44
|
+
## Tool selection
|
|
45
|
+
- Known issue identifier (e.g. ENG-123): get_issue.
|
|
46
|
+
- Discovery or filters (status, assignee, team, text): list_issues. For the current user's issues, use assignee "me".
|
|
47
|
+
- Projects: list_projects to browse, get_project for one project.
|
|
48
|
+
- Teams, workflow states, and labels: list_teams, get_team, list_issue_statuses, list_issue_labels.
|
|
49
|
+
- Current user: get_user.
|
|
50
|
+
- Comments: list_comments, create_comment.
|
|
51
|
+
- Product help: search_documentation.
|
|
52
|
+
|
|
53
|
+
## Create and update workflow
|
|
54
|
+
1. Resolve team, status, label, assignee, and project details via list_teams, list_issue_statuses, list_issue_labels, list_projects, or get_user as needed.
|
|
55
|
+
2. Perform the mutation with create_issue, update_issue, create_project, or update_project.
|
|
56
|
+
3. Summarize what changed, including identifiers (e.g. ENG-123), titles, and key fields.
|
|
57
|
+
|
|
58
|
+
Priority values: 0 = No priority, 1 = Urgent, 2 = High, 3 = Normal, 4 = Low.
|
|
59
|
+
|
|
60
|
+
## Response style
|
|
61
|
+
Reply in concise plain text. Lead with the outcome, then key details (identifiers, titles, status, assignee). Mention if you searched but found nothing.`;
|
|
62
|
+
export async function* runLinearAgent(args) {
|
|
63
|
+
const model = resolveOpenAiModel(args.model ?? "openai/gpt-4o-mini", {
|
|
64
|
+
authMode: args.authMode,
|
|
65
|
+
openaiApiKey: args.openaiApiKey,
|
|
34
66
|
});
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
67
|
+
const mcpSession = await createLinearMcpClient({
|
|
68
|
+
apiKey: args.apiKey,
|
|
69
|
+
}).catch((error) => {
|
|
70
|
+
throwMcpError("launch", error);
|
|
71
|
+
});
|
|
72
|
+
const queue = [];
|
|
73
|
+
let wake;
|
|
74
|
+
let agentDone = false;
|
|
75
|
+
let agentError;
|
|
76
|
+
const enqueue = (event) => {
|
|
77
|
+
queue.push(event);
|
|
78
|
+
wake?.();
|
|
79
|
+
wake = undefined;
|
|
80
|
+
};
|
|
81
|
+
const waitForQueue = () => new Promise((resolve) => {
|
|
82
|
+
wake = resolve;
|
|
83
|
+
});
|
|
84
|
+
const agentTask = (async () => {
|
|
85
|
+
try {
|
|
86
|
+
const rawTools = await mcpSession.client.tools().catch((error) => {
|
|
87
|
+
throwMcpError("tools", error);
|
|
88
|
+
});
|
|
89
|
+
if (Object.keys(rawTools).length === 0) {
|
|
90
|
+
throwMcpError("tools", new Error("Linear MCP server returned no tools"));
|
|
91
|
+
}
|
|
92
|
+
const tools = wrapMcpTools(rawTools);
|
|
93
|
+
const prompt = args.threadContext
|
|
94
|
+
? `${args.threadContext}\n\nCurrent message:\n${args.prompt}`
|
|
95
|
+
: args.prompt;
|
|
96
|
+
const result = await generateText({
|
|
97
|
+
model,
|
|
98
|
+
system: SYSTEM_PROMPT,
|
|
99
|
+
prompt,
|
|
100
|
+
tools,
|
|
101
|
+
stopWhen: stepCountIs(10),
|
|
102
|
+
onToolExecutionStart: ({ toolCall }) => {
|
|
103
|
+
enqueue({
|
|
104
|
+
kind: "widget",
|
|
105
|
+
widget: toolCallWidget({
|
|
106
|
+
widgetId: toolCall.toolCallId,
|
|
107
|
+
title: toolCall.toolName,
|
|
108
|
+
}),
|
|
109
|
+
});
|
|
110
|
+
},
|
|
111
|
+
onToolExecutionEnd: ({ toolCall, toolOutput }) => {
|
|
112
|
+
enqueue({
|
|
113
|
+
kind: "widget",
|
|
114
|
+
widget: toolCallWidget({
|
|
115
|
+
widgetId: toolCall.toolCallId,
|
|
116
|
+
title: toolCall.toolName,
|
|
117
|
+
body: formatToolOutput(toolOutput),
|
|
118
|
+
}),
|
|
119
|
+
});
|
|
120
|
+
},
|
|
121
|
+
}).catch((error) => {
|
|
122
|
+
throwMcpError("agent", error);
|
|
123
|
+
});
|
|
124
|
+
enqueue({
|
|
125
|
+
kind: "reply",
|
|
126
|
+
content: result.text.trim() || "Done.",
|
|
127
|
+
});
|
|
39
128
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
text: `Linear tool error: ${toolErrors[0]}`,
|
|
57
|
-
issues,
|
|
58
|
-
usedTools: toolResults.length > 0,
|
|
59
|
-
toolErrors,
|
|
60
|
-
};
|
|
129
|
+
catch (error) {
|
|
130
|
+
agentError = error;
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
agentDone = true;
|
|
134
|
+
wake?.();
|
|
135
|
+
wake = undefined;
|
|
136
|
+
}
|
|
137
|
+
})();
|
|
138
|
+
try {
|
|
139
|
+
while (!agentDone || queue.length > 0) {
|
|
140
|
+
if (queue.length === 0) {
|
|
141
|
+
await waitForQueue();
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
yield queue.shift();
|
|
61
145
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
usedTools: toolResults.length > 0,
|
|
66
|
-
toolErrors,
|
|
67
|
-
};
|
|
146
|
+
await agentTask;
|
|
147
|
+
if (agentError)
|
|
148
|
+
throw agentError;
|
|
68
149
|
}
|
|
69
150
|
finally {
|
|
70
|
-
await
|
|
151
|
+
await mcpSession.client.close().catch(() => undefined);
|
|
71
152
|
}
|
|
72
153
|
}
|
package/dist/linear-mcp.js
CHANGED
|
@@ -1,26 +1,14 @@
|
|
|
1
|
-
import { createRequire } from "node:module";
|
|
2
1
|
import { createMCPClient } from "@ai-sdk/mcp";
|
|
3
|
-
|
|
4
|
-
const require = createRequire(import.meta.url);
|
|
5
|
-
function resolveMcpServerLinearLaunch() {
|
|
6
|
-
try {
|
|
7
|
-
const entry = require.resolve("mcp-server-linear");
|
|
8
|
-
return { command: process.execPath, args: [entry] };
|
|
9
|
-
}
|
|
10
|
-
catch {
|
|
11
|
-
return { command: "npx", args: ["-y", "mcp-server-linear"] };
|
|
12
|
-
}
|
|
13
|
-
}
|
|
2
|
+
const LINEAR_MCP_URL = "https://mcp.linear.app/mcp";
|
|
14
3
|
export async function createLinearMcpClient(args) {
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
...process.env,
|
|
22
|
-
LINEAR_ACCESS_TOKEN: args.accessToken,
|
|
4
|
+
const client = await createMCPClient({
|
|
5
|
+
transport: {
|
|
6
|
+
type: "http",
|
|
7
|
+
url: LINEAR_MCP_URL,
|
|
8
|
+
headers: {
|
|
9
|
+
Authorization: `Bearer ${args.apiKey}`,
|
|
23
10
|
},
|
|
24
|
-
}
|
|
11
|
+
},
|
|
25
12
|
});
|
|
13
|
+
return { client };
|
|
26
14
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
function summarizeMessage(message, maxLength = 240) {
|
|
2
|
+
const trimmed = message.replace(/\s+/g, " ").trim();
|
|
3
|
+
if (trimmed.length <= maxLength)
|
|
4
|
+
return trimmed;
|
|
5
|
+
return `${trimmed.slice(0, maxLength - 1)}…`;
|
|
6
|
+
}
|
|
7
|
+
export function formatMcpUserError(error, phase) {
|
|
8
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
9
|
+
const lower = message.toLowerCase();
|
|
10
|
+
if (phase === "launch" ||
|
|
11
|
+
lower.includes("enoent") ||
|
|
12
|
+
lower.includes("err_invalid_arg_value") ||
|
|
13
|
+
(lower.includes("spawn") && lower.includes("npx")) ||
|
|
14
|
+
lower.includes("command not found")) {
|
|
15
|
+
return "Could not connect to Linear MCP at mcp.linear.app. Check your network access and API key, then try again.";
|
|
16
|
+
}
|
|
17
|
+
if (lower.includes("401") ||
|
|
18
|
+
lower.includes("unauthorized") ||
|
|
19
|
+
lower.includes("invalid api key") ||
|
|
20
|
+
lower.includes("invalid token") ||
|
|
21
|
+
(lower.includes("authentication") && lower.includes("linear"))) {
|
|
22
|
+
return "Linear API key appears invalid or expired. Update your personal API key in plugin config or the LINEAR_API_KEY workspace secret.";
|
|
23
|
+
}
|
|
24
|
+
if ((lower.includes("connection") && lower.includes("closed")) ||
|
|
25
|
+
lower.includes("transport closed") ||
|
|
26
|
+
lower.includes("econnreset") ||
|
|
27
|
+
lower.includes("econnrefused")) {
|
|
28
|
+
return "The Linear MCP server disconnected unexpectedly. Try your request again; if it keeps failing, restart the OpenBot runtime.";
|
|
29
|
+
}
|
|
30
|
+
if (phase === "tools") {
|
|
31
|
+
return `Could not connect to Linear MCP tools: ${summarizeMessage(message)}`;
|
|
32
|
+
}
|
|
33
|
+
if (phase === "agent") {
|
|
34
|
+
return `Linear agent failed while talking to MCP: ${summarizeMessage(message)}`;
|
|
35
|
+
}
|
|
36
|
+
return `Could not start the Linear MCP server: ${summarizeMessage(message)}`;
|
|
37
|
+
}
|
|
38
|
+
export function throwMcpError(phase, error) {
|
|
39
|
+
throw new Error(formatMcpUserError(error, phase));
|
|
40
|
+
}
|