@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/dist/index.js CHANGED
@@ -1,250 +1,42 @@
1
- import { agentOutput, definePlugin, shouldHandleInvoke, toolResult, uiWidget, webhookHttpResponse, } from "@meetopenbot/plugin-sdk";
2
- import { clearTokens, formatMissingCredentials, readLinearConfig, resolveLinearCredentials, resolveWebhookBaseUrl, saveTokens, } from "./config.js";
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 { buildIssuesListWidget, isAssignedIssuesPrompt, isListIssuesPrompt, issuesListTitle, } from "./linear-issues.js";
5
- import { OAUTH_WEBHOOK_PROVIDER, buildOAuthRedirectUri, fetchViewer, handleWebhookOAuthCallback, startOAuthFlow, startWebhookOAuthFlow, } from "./oauth.js";
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
- clientId: {
172
- type: "string",
173
- description: "Linear OAuth application Client ID (create one at linear.app/settings/api/applications)",
174
- },
175
- clientSecret: {
176
- type: "string",
177
- description: "Linear OAuth application Client Secret (optional — PKCE is used when omitted)",
178
- format: "password",
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 (alternative to OAuth)",
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 OAuth connect flow and MCP-backed agent for issues, projects, and comments",
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: notConnectedMessage(auth.missing),
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 reply = await runLinearAgent({
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
- accessToken: auth.credentials.accessToken,
82
+ apiKey: auth.credentials.apiKey,
283
83
  model: auth.credentials.model,
284
- });
285
- const issues = reply.issues;
286
- const shouldListIssues = isListIssuesPrompt(userMessage) || isAssignedIssuesPrompt(userMessage);
287
- if (shouldListIssues || issues.length > 0) {
288
- yield uiWidget({
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: `Linear agent error: ${message}`,
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
  });
@@ -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
- const SYSTEM_PROMPT = `You are the OpenBot Linear specialist agent.
6
- You MUST use the provided Linear MCP tools for every request about issues, projects, teams, or comments.
7
- Never guess or invent Linear data. If a tool returns no results, say so explicitly.
8
- For listing or searching issues, call linear_get_user first when the user asks for issues assigned to them, then call linear_search_issues with the correct filters.
9
- Summarize tool results clearly in plain text. Be concise and friendly.`;
10
- function extractToolErrors(toolResults) {
11
- const errors = [];
12
- for (const toolResult of toolResults) {
13
- const output = toolResult.output;
14
- if (!output || typeof output !== "object")
15
- continue;
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
- return errors;
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
- export async function runLinearAgent(args) {
31
- const openai = createOpenAI({ apiKey: args.openaiApiKey });
32
- const mcpClient = await createLinearMcpClient({
33
- accessToken: args.accessToken,
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
- try {
36
- const tools = await mcpClient.tools();
37
- if (Object.keys(tools).length === 0) {
38
- throw new Error("Linear MCP server returned no tools. Check that mcp-server-linear is installed and reachable.");
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
- const result = await generateText({
41
- model: openai(args.model ?? "gpt-4o-mini"),
42
- system: SYSTEM_PROMPT,
43
- prompt: args.prompt,
44
- tools,
45
- stopWhen: stepCountIs(10),
46
- });
47
- const toolResults = result.steps.flatMap((step) => step.toolResults.map((toolResult) => ({
48
- toolName: toolResult.toolName,
49
- output: toolResult.output,
50
- })));
51
- const issues = extractIssuesFromToolResults(toolResults);
52
- const toolErrors = extractToolErrors(toolResults);
53
- const text = result.text.trim();
54
- if (!text && toolErrors.length > 0) {
55
- return {
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
- return {
63
- text: text || (issues.length > 0 ? `Found ${issues.length} issue(s).` : "Done."),
64
- issues,
65
- usedTools: toolResults.length > 0,
66
- toolErrors,
67
- };
146
+ await agentTask;
147
+ if (agentError)
148
+ throw agentError;
68
149
  }
69
150
  finally {
70
- await mcpClient.close();
151
+ await mcpSession.client.close().catch(() => undefined);
71
152
  }
72
153
  }
@@ -1,26 +1,14 @@
1
- import { createRequire } from "node:module";
2
1
  import { createMCPClient } from "@ai-sdk/mcp";
3
- import { Experimental_StdioMCPTransport } from "@ai-sdk/mcp/mcp-stdio";
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 launch = resolveMcpServerLinearLaunch();
16
- return createMCPClient({
17
- transport: new Experimental_StdioMCPTransport({
18
- command: launch.command,
19
- args: launch.args,
20
- env: {
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
+ }