@yannelli/paseo-linear-plugin 1.1.0-beta.0 → 1.1.0-beta.2

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.
Files changed (44) hide show
  1. package/OVERVIEW.md +7 -3
  2. package/README.md +38 -11
  3. package/client/access.tsx +2 -1
  4. package/client/compat.ts +23 -5
  5. package/client/connect.tsx +3 -2
  6. package/client/graph-camera.tsx +219 -0
  7. package/client/graph-labels.ts +14 -4
  8. package/client/launch-guidance.tsx +86 -0
  9. package/client/launch-plan.ts +50 -2
  10. package/client/launch.tsx +10 -1
  11. package/client/live-agents.tsx +138 -23
  12. package/client/live-cursor.tsx +3 -1
  13. package/client/live-graph.tsx +159 -75
  14. package/client/live-header.tsx +29 -0
  15. package/client/live-panel.tsx +29 -8
  16. package/client/live-timeline.ts +38 -3
  17. package/client/pill.tsx +4 -3
  18. package/client/plugin-icon.tsx +43 -0
  19. package/client/settings-fields.tsx +11 -3
  20. package/client/settings-icon.tsx +228 -0
  21. package/client/settings-live.tsx +25 -1
  22. package/client/timeline-card.tsx +2 -1
  23. package/client/tool-buttons.tsx +68 -0
  24. package/client/ui.tsx +16 -7
  25. package/client/web.ts +91 -0
  26. package/index.client.tsx +9 -0
  27. package/index.server.ts +5 -1
  28. package/package.json +1 -1
  29. package/server/agent-hooks.ts +37 -0
  30. package/server/agent-runs.ts +15 -2
  31. package/server/agent-tools.ts +240 -0
  32. package/server/init.ts +1 -0
  33. package/server/live.ts +27 -1
  34. package/server/mcp-http.ts +152 -0
  35. package/server/stores.ts +8 -1
  36. package/server/subagent-logs.ts +146 -0
  37. package/shared/activity.ts +5 -1
  38. package/shared/agent-hooks.ts +68 -0
  39. package/shared/agent-tools.ts +130 -0
  40. package/shared/custom-icon.ts +148 -0
  41. package/shared/graph-camera.ts +93 -0
  42. package/shared/live.ts +43 -0
  43. package/shared/prompts.ts +49 -1
  44. package/shared/settings.ts +34 -0
@@ -20,6 +20,8 @@ export interface InternalRun<T> {
20
20
  /** Folder the agent works in; its reads must stay inside. */
21
21
  cwd: string;
22
22
  provider: string;
23
+ /** Thinking option for the model in `provider`; ignored when another model runs. */
24
+ effort?: string;
23
25
  title: string;
24
26
  prompt: string;
25
27
  labels: Record<string, string>;
@@ -144,7 +146,7 @@ export async function agentConfigFor(paseo: Paseo, preferred: string) {
144
146
  return { provider, ...(modeId ? { modeId } : {}) };
145
147
  }
146
148
 
147
- async function lastReply(handle: AgentHandle) {
149
+ export async function lastReply(handle: AgentHandle) {
148
150
  const page = await handle.timeline.refetch({
149
151
  direction: "tail",
150
152
  limit: 60,
@@ -221,8 +223,14 @@ export interface StartedRun<T> {
221
223
  result: Promise<T>;
222
224
  }
223
225
 
226
+ /** Adds the chosen effort only when the chosen model runs, not a fallback provider. */
227
+ export function withEffort<C extends { provider: string }>(config: C, chosen: string, effort?: string) {
228
+ const applies = Boolean(effort) && chosen.includes("/") && config.provider === chosen;
229
+ return applies ? { ...config, thinkingOptionId: effort as string } : config;
230
+ }
231
+
224
232
  export async function startInternalRun<T>(run: InternalRun<T>): Promise<StartedRun<T>> {
225
- const config = await agentConfigFor(run.paseo, run.provider);
233
+ const config = withEffort(await agentConfigFor(run.paseo, run.provider), run.provider, run.effort);
226
234
  const handle = await run.workspace.agents.create({
227
235
  config,
228
236
  title: run.title,
@@ -239,6 +247,8 @@ export interface JobStore<T> {
239
247
  start(id: string | null, begin: () => Promise<StartedRun<T>>): Promise<Job>;
240
248
  /** Resolves with the job once it is done or failed; null for an unknown id. */
241
249
  wait(id: string): Promise<Job | null>;
250
+ /** Drops a finished job, so the next start makes a new run. A running job is kept. */
251
+ forget(id: string): void;
242
252
  }
243
253
 
244
254
  interface JobRecord<T> {
@@ -254,6 +264,9 @@ export function createJobStore<T>(): JobStore<T> {
254
264
  const record = jobs.get(id);
255
265
  return record ? { job: record.job, value: record.value } : null;
256
266
  },
267
+ forget(id) {
268
+ if (jobs.get(id)?.job.status !== "running") jobs.delete(id);
269
+ },
257
270
  async wait(id) {
258
271
  const record = jobs.get(id);
259
272
  if (!record) return null;
@@ -0,0 +1,240 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import path from "node:path";
3
+ import type { PluginServerContext } from "@getpaseo/plugin/server";
4
+ import { z } from "zod";
5
+ import {
6
+ AGENT_TOOLS,
7
+ agentToolsRpc,
8
+ childBrief,
9
+ EditIssueInput,
10
+ editText,
11
+ issueText,
12
+ LINEAR_TOOLS,
13
+ ReadIssueInput,
14
+ StartAgentInput,
15
+ TOOLS_SERVER,
16
+ taskCount,
17
+ type ToolScope,
18
+ ToolScopeSchema,
19
+ toolPolicy,
20
+ WaitAgentInput,
21
+ } from "../shared/agent-tools";
22
+ import { AGENT_LABELS } from "../shared/prompts";
23
+ import { lastReply, type Paseo } from "./agent-runs";
24
+ import type { LinearAccess } from "./handlers";
25
+ import { createMcpServer, listenLocal, type McpTool, tool } from "./mcp-http";
26
+ import type { LinearService } from "./queries";
27
+ import { createJsonFile } from "./stores";
28
+
29
+ // Each launch gets a grant: a random token for the agent's MCP config, and the issue tree it
30
+ // reaches. The file keeps the port and a hash of each token, never the token, so agents keep
31
+ // their tools after a restart.
32
+
33
+ const MAX_GRANTS = 500;
34
+
35
+ const GrantSchema = z.object({
36
+ id: z.string(),
37
+ hash: z.string(),
38
+ scope: ToolScopeSchema,
39
+ /** Only agents started from an issue may start agents, so the tree is one level deep. */
40
+ canStart: z.boolean(),
41
+ createdAt: z.string(),
42
+ });
43
+ export type Grant = z.infer<typeof GrantSchema>;
44
+
45
+ const FileSchema = z.object({ port: z.number().int().nullable(), grants: z.array(GrantSchema) });
46
+
47
+ const hashOf = (token: string) => createHash("sha256").update(token).digest("hex");
48
+
49
+ export interface AgentToolsDependencies {
50
+ access: LinearAccess;
51
+ dataDirectory: string;
52
+ }
53
+
54
+ /** The root issue or one of its sub-issues; any other key is refused. */
55
+ async function treeIssue(linear: LinearService, scope: ToolScope, key: string | undefined) {
56
+ const wanted = (key ?? scope.home).toUpperCase();
57
+ const root = await linear.getIssue(scope.root);
58
+ if (wanted === root.identifier.toUpperCase()) return root;
59
+ if (!root.children.some((child) => child.identifier.toUpperCase() === wanted)) {
60
+ throw new Error(`${wanted} is not ${root.identifier} or one of its sub-issues.`);
61
+ }
62
+ return linear.getIssue(wanted);
63
+ }
64
+
65
+ export function linearTools(deps: AgentToolsDependencies, paseo: () => Paseo, mint: Mint): McpTool<Grant>[] {
66
+ const callers = new Map<string, string>();
67
+ async function callerAgent(api: Paseo, grant: Grant): Promise<string | null> {
68
+ const known = callers.get(grant.id);
69
+ if (known) return known;
70
+ const result = await api.agents.list({ filter: { labels: { [AGENT_LABELS.tools]: grant.id } }, page: { limit: 1 } });
71
+ const id = result.entries[0]?.agent.id ?? null;
72
+ if (id) callers.set(grant.id, id);
73
+ return id;
74
+ }
75
+ return [
76
+ tool({
77
+ name: "read_issue",
78
+ description: "Read a Linear issue of your issue tree: status, description with its task list, and sub-issues.",
79
+ input: ReadIssueInput,
80
+ async run(input, grant) {
81
+ const { linear } = await deps.access.connect(grant.scope.keyScope);
82
+ return issueText(await treeIssue(linear, grant.scope, input.issue));
83
+ },
84
+ }),
85
+ tool({
86
+ name: "edit_issue",
87
+ description:
88
+ "Change the description of a Linear issue of your issue tree. Replace exact text, for example `- [ ] Add the form` with `- [x] Add the form` to check off a task list item.",
89
+ input: EditIssueInput,
90
+ run(input, grant) {
91
+ return deps.access.mutate(grant.scope.keyScope, async (linear) => {
92
+ const issue = await treeIssue(linear, grant.scope, input.issue);
93
+ const edited = editText(issue.description ?? "", input.old_text, input.new_text);
94
+ if ("error" in edited) throw new Error(edited.error);
95
+ await linear.updateIssue(issue.id, { description: edited.body });
96
+ const tasks = taskCount(edited.body);
97
+ const count = tasks.total > 0 ? ` ${tasks.done} of ${tasks.total} task list items are checked.` : "";
98
+ return `Updated the description of ${issue.identifier}.${count}`;
99
+ });
100
+ },
101
+ }),
102
+ tool({
103
+ name: "start_agent",
104
+ description:
105
+ "Start a Paseo agent on a sub-issue, in your workspace, on your provider and model. It gets read_issue and edit_issue. Returns its id at once.",
106
+ input: StartAgentInput,
107
+ allowed: (grant) => grant.canStart,
108
+ async run(input, grant) {
109
+ const api = paseo();
110
+ const key = input.issue.toUpperCase();
111
+ const { linear } = await deps.access.connect(grant.scope.keyScope);
112
+ await treeIssue(linear, grant.scope, key);
113
+ const parent = await callerAgent(api, grant);
114
+ const child = await mint({ ...grant.scope, home: key }, false);
115
+ const { keyScope } = grant.scope;
116
+ const create = (config: ToolScope["config"]) =>
117
+ api.workspaces.ref(grant.scope.workspaceId).agents.create({
118
+ config: { ...config, mcpServers: { [TOOLS_SERVER]: child.server }, toolPolicy: child.toolPolicy },
119
+ ...(parent ? { parent } : {}),
120
+ title: input.title,
121
+ prompt: `${childBrief(key, grant.scope.root)}\n\n${input.prompt}`,
122
+ labels: {
123
+ [AGENT_LABELS.issue]: key,
124
+ [AGENT_LABELS.action]: "implement",
125
+ ...(keyScope ? { [AGENT_LABELS.project]: keyScope } : {}),
126
+ [AGENT_LABELS.tools]: child.grantId,
127
+ [AGENT_LABELS.startedBy]: grant.id,
128
+ },
129
+ });
130
+ const base = grant.scope.config;
131
+ let note = "";
132
+ let handle: Awaited<ReturnType<typeof create>>;
133
+ try {
134
+ handle = await create(input.thinking ? { ...base, thinkingOptionId: input.thinking } : base);
135
+ } catch (error) {
136
+ if (!input.thinking) throw error;
137
+ handle = await create(base);
138
+ note = ` The model has no thinking option "${input.thinking}", so it uses yours.`;
139
+ }
140
+ return `Started agent ${handle.id} on ${key}.${note} Call wait_agent with agent_id "${handle.id}" to get its result.`;
141
+ },
142
+ }),
143
+ tool({
144
+ name: "wait_agent",
145
+ description: "Wait for an agent that start_agent started, and get its last reply. Call it again while the agent works.",
146
+ input: WaitAgentInput,
147
+ allowed: (grant) => grant.canStart,
148
+ async run(input, grant) {
149
+ const id = input.agent_id;
150
+ const handle = paseo().agents.ref(id);
151
+ const snapshot = (await handle.refresh().catch(() => null))?.agent;
152
+ if (snapshot?.labels?.[AGENT_LABELS.startedBy] !== grant.id) {
153
+ throw new Error(`Agent ${id} was not started by your start_agent calls.`);
154
+ }
155
+ const result = await handle.waitForFinish(input.timeout_seconds * 1000);
156
+ if (result.status === "timeout") return `Agent ${id} is still working. Call wait_agent again.`;
157
+ if (result.status === "permission") return `Agent ${id} waits for the user to answer a permission request in Paseo.`;
158
+ if (result.status === "error") return `Agent ${id} stopped with an error: ${result.error ?? "unknown error"}`;
159
+ const reply = result.lastMessage ?? (await lastReply(handle));
160
+ return `Agent ${id} finished.\n\nIts last reply:\n${reply || "(no reply)"}`;
161
+ },
162
+ }),
163
+ ];
164
+ }
165
+
166
+ type Minted = z.infer<typeof agentToolsRpc.output>;
167
+ type Mint = (scope: ToolScope, canStart: boolean) => Promise<Minted>;
168
+
169
+ export function registerAgentTools(server: PluginServerContext, deps: AgentToolsDependencies) {
170
+ const file = createJsonFile(path.join(deps.dataDirectory, "agent-tools.json"), FileSchema, () => ({
171
+ port: null,
172
+ grants: [],
173
+ }));
174
+ // The SDK hands Paseo to handlers and hooks only. The subprocess has one, so keep the latest.
175
+ let api: Paseo | null = null;
176
+ const remember = (_event: unknown, context: { paseo: Paseo }) => {
177
+ api = context.paseo;
178
+ };
179
+ server.on("agent.turn_started", remember);
180
+ server.on("agent.turn_ended", remember);
181
+ server.on("agent.created", remember);
182
+ const paseo = () => {
183
+ if (!api) throw new Error("Paseo is not ready yet. Try again in a few seconds.");
184
+ return api;
185
+ };
186
+
187
+ let listening: Promise<number> | null = null;
188
+ const ensureListening = () => {
189
+ listening ??= file
190
+ .read()
191
+ .then((current) => listenLocal(http, current.port))
192
+ .then(async (port) => {
193
+ await file.update((current) => ({ next: { ...current, port }, result: null }));
194
+ return port;
195
+ });
196
+ return listening;
197
+ };
198
+ const mint: Mint = async (scope, canStart) => {
199
+ const port = await ensureListening();
200
+ const token = randomBytes(32).toString("base64url");
201
+ const grant: Grant = {
202
+ id: randomBytes(8).toString("hex"),
203
+ hash: hashOf(token),
204
+ scope,
205
+ canStart,
206
+ createdAt: new Date().toISOString(),
207
+ };
208
+ await file.update((current) => ({ next: { ...current, grants: [...current.grants, grant].slice(-MAX_GRANTS) }, result: null }));
209
+ return {
210
+ grantId: grant.id,
211
+ server: { type: "http", url: `http://127.0.0.1:${port}/mcp`, headers: { Authorization: `Bearer ${token}` } },
212
+ toolPolicy: toolPolicy(canStart ? [...LINEAR_TOOLS, ...AGENT_TOOLS] : [...LINEAR_TOOLS]),
213
+ };
214
+ };
215
+ const http = createMcpServer<Grant>({
216
+ name: "paseo-linear",
217
+ version: "1.0.0",
218
+ tools: linearTools(deps, paseo, mint),
219
+ async authorize(token) {
220
+ const hash = hashOf(token);
221
+ return (await file.read()).grants.find((grant) => grant.hash === hash) ?? null;
222
+ },
223
+ });
224
+
225
+ server.handle(agentToolsRpc, async (scope, context) => {
226
+ api = context.paseo;
227
+ return mint(scope, true);
228
+ });
229
+ // Agents from before a restart call the same port, so listen again when grants exist.
230
+ void file
231
+ .read()
232
+ .then((current) => (current.grants.length > 0 ? ensureListening() : null))
233
+ .catch((error: unknown) => console.error("Linear MCP could not start", error));
234
+
235
+ return () =>
236
+ new Promise<void>((resolve) => {
237
+ http.closeAllConnections();
238
+ http.close(() => resolve());
239
+ });
240
+ }
package/server/init.ts CHANGED
@@ -47,6 +47,7 @@ export function registerInit(server: PluginServerContext, dependencies: InitDepe
47
47
  workspace: await paseo.workspaces.open(rootPath),
48
48
  cwd: rootPath,
49
49
  provider: settings?.live.exploreProvider || settings?.launch.provider || "",
50
+ effort: settings?.live.exploreProvider ? settings.live.exploreEffort : "",
50
51
  title: `Set up Linear prompts for ${project.projectDisplayName}`,
51
52
  prompt: initPrompt(rootPath),
52
53
  labels: { [AGENT_LABELS.init]: projectId },
package/server/live.ts CHANGED
@@ -11,10 +11,13 @@ import {
11
11
  type Job,
12
12
  type LaunchInput,
13
13
  launchAfterExploreRpc,
14
+ clearMapRpc,
14
15
  linksRpc,
15
16
  listFilesRpc,
17
+ subagentLogsRpc,
16
18
  liveMapRpc,
17
19
  MAX_DIR_ENTRIES,
20
+ MAX_EXPLORE_FILES,
18
21
  } from "../shared/live";
19
22
  import { AGENT_LABELS, issueSnapshot } from "../shared/prompts";
20
23
  import { type LinearSettings, projectEnabled } from "../shared/settings";
@@ -27,6 +30,7 @@ import {
27
30
  } from "./agent-runs";
28
31
  import type { LinearAccess } from "./handlers";
29
32
  import { fileLinks } from "./links";
33
+ import { subagentLogs } from "./subagent-logs";
30
34
  import type { LaunchQueue, MapStore } from "./stores";
31
35
 
32
36
  const EXPLORE_TIMEOUT_MS = 20 * 60_000;
@@ -41,6 +45,8 @@ export interface AgentInfo {
41
45
  /** Paseo project of the agent's workspace, when the workspace is known. */
42
46
  projectId: string | null;
43
47
  provider: string;
48
+ /** The provider's session, such as Claude's session id. */
49
+ sessionId: string | null;
44
50
  labels: Record<string, string>;
45
51
  }
46
52
 
@@ -62,6 +68,7 @@ export async function agentInfo(paseo: Paseo, agentId: string): Promise<AgentInf
62
68
  workspaceId,
63
69
  projectId: workspace?.projectId ?? null,
64
70
  provider: agent.provider,
71
+ sessionId: agent.runtimeInfo?.sessionId ?? null,
65
72
  labels: agent.labels ?? {},
66
73
  };
67
74
  }
@@ -100,7 +107,7 @@ export function explorePrompt(issue: IssueDetail): string {
100
107
  issueSnapshot(issue, { includeComments: false }),
101
108
  "",
102
109
  "List the files an engineer will most likely read or change for this issue and each sub-issue.",
103
- "Include files that will probably be created, at their expected path. List at most 40 files.",
110
+ `Include files that will probably be created, at their expected path. List at most ${MAX_EXPLORE_FILES} files.`,
104
111
  `Set "issue" to the sub-issue key the file belongs to, or ${issue.identifier} for the whole issue.`,
105
112
  `Valid keys: ${keys.join(", ")}.`,
106
113
  "Reply with only a JSON object in a ```json block, with repo-relative paths:",
@@ -223,6 +230,21 @@ export function registerLive(server: PluginServerContext, dependencies: LiveDepe
223
230
  return { links: await fileLinks(agent.cwd, files) };
224
231
  });
225
232
 
233
+ server.handle(clearMapRpc, async ({ agentId }, { paseo }) => {
234
+ const agent = await agentInfo(paseo, agentId);
235
+ const identifier = agent.labels[AGENT_LABELS.issue];
236
+ if (!identifier) throw new Error("This agent is not linked to a Linear issue");
237
+ if (jobs.get(identifier)?.job.status === "running") throw new Error("The explore agent is still mapping this issue");
238
+ await maps.delete(identifier);
239
+ jobs.forget(identifier);
240
+ return { cleared: true };
241
+ });
242
+ server.handle(subagentLogsRpc, async ({ agentId }, { paseo }) => {
243
+ const agent = await agentInfo(paseo, agentId);
244
+ // Only Claude keeps subagent transcripts this plugin can read.
245
+ if (agent.provider.split("/")[0] !== "claude" || !agent.sessionId) return { runs: [] };
246
+ return { runs: await subagentLogs(agent.sessionId, agent.cwd) };
247
+ });
226
248
  server.handle(liveMapRpc, async ({ identifier }, { paseo }) => {
227
249
  const map = await maps.get(identifier);
228
250
  let job = jobs.get(identifier)?.job ?? null;
@@ -249,6 +271,7 @@ export function registerLive(server: PluginServerContext, dependencies: LiveDepe
249
271
  /** Opened only when a new run starts. */
250
272
  workspace: () => Promise<WorkspaceHandle>;
251
273
  provider: string;
274
+ effort: string;
252
275
  force: boolean;
253
276
  }
254
277
 
@@ -275,6 +298,7 @@ export function registerLive(server: PluginServerContext, dependencies: LiveDepe
275
298
  workspace: await target.workspace(),
276
299
  cwd: target.cwd,
277
300
  provider: target.provider,
301
+ effort: target.effort,
278
302
  title: `Explore ${identifier}`,
279
303
  prompt: explorePrompt(issue),
280
304
  labels,
@@ -306,6 +330,7 @@ export function registerLive(server: PluginServerContext, dependencies: LiveDepe
306
330
  workspace: async () =>
307
331
  workspaceId ? paseo.workspaces.ref(workspaceId) : paseo.workspaces.open(cwd),
308
332
  provider: settings?.live.exploreProvider || agent.provider,
333
+ effort: settings?.live.exploreEffort ?? "",
309
334
  force,
310
335
  });
311
336
  return { job };
@@ -322,6 +347,7 @@ export function registerLive(server: PluginServerContext, dependencies: LiveDepe
322
347
  cwd: info.workspaceDirectory ?? info.projectRootPath,
323
348
  workspace: async () => workspace,
324
349
  provider: settings?.live.exploreProvider || input.agent.config.provider,
350
+ effort: settings?.live.exploreEffort ?? "",
325
351
  force: false,
326
352
  });
327
353
  // A failed or timed-out explore run still starts the agent; the map falls back to ticket text.
@@ -0,0 +1,152 @@
1
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
2
+ import type { AddressInfo } from "node:net";
3
+ import { type ZodType, z } from "zod";
4
+
5
+ // A small MCP server over HTTP for agents on this host. It sends plain JSON responses, which the
6
+ // streamable HTTP transport allows, so it needs no sessions and no event streams.
7
+
8
+ export interface McpTool<C> {
9
+ name: string;
10
+ description: string;
11
+ input: ZodType;
12
+ /** Callers that may list and call the tool; all callers when absent. */
13
+ allowed?(caller: C): boolean;
14
+ run(input: unknown, caller: C): Promise<string>;
15
+ }
16
+
17
+ /** A tool whose run gets its parsed input. */
18
+ export function tool<S extends ZodType, C>(
19
+ definition: Omit<McpTool<C>, "input" | "run"> & { input: S; run(input: z.output<S>, caller: C): Promise<string> },
20
+ ): McpTool<C> {
21
+ return definition as unknown as McpTool<C>;
22
+ }
23
+
24
+ export interface McpOptions<C> {
25
+ name: string;
26
+ version: string;
27
+ tools: readonly McpTool<C>[];
28
+ /** The caller for a bearer token, or null to refuse the request. */
29
+ authorize(token: string): Promise<C | null>;
30
+ }
31
+
32
+ const VERSIONS = ["2025-06-18", "2025-03-26", "2024-11-05"];
33
+ const MAX_BODY = 1_000_000;
34
+
35
+ type Message = { jsonrpc?: unknown; id?: unknown; method?: unknown; params?: unknown };
36
+
37
+ const failure = (id: unknown, code: number, message: string) => ({ jsonrpc: "2.0", id: id ?? null, error: { code, message } });
38
+ const success = (id: unknown, result: unknown) => ({ jsonrpc: "2.0", id, result });
39
+ const text = (value: string, isError = false) => ({ content: [{ type: "text", text: value }], ...(isError ? { isError } : {}) });
40
+
41
+ function errorText(error: unknown): string {
42
+ if (error instanceof z.ZodError) return error.issues.map((issue) => `${issue.path.join(".") || "input"}: ${issue.message}`).join("; ");
43
+ return error instanceof Error ? error.message : String(error);
44
+ }
45
+
46
+ /** The answer to one JSON-RPC message, or null for a notification. */
47
+ export async function answer<C>(options: McpOptions<C>, message: Message, caller: C): Promise<object | null> {
48
+ if (typeof message !== "object" || message === null || message.jsonrpc !== "2.0" || typeof message.method !== "string") {
49
+ return failure(message?.id, -32600, "Invalid request");
50
+ }
51
+ if (message.id === undefined || message.id === null) return null;
52
+ const params = (typeof message.params === "object" && message.params !== null ? message.params : {}) as Record<string, unknown>;
53
+ const tools = options.tools.filter((tool) => tool.allowed?.(caller) ?? true);
54
+ switch (message.method) {
55
+ case "initialize": {
56
+ const asked = typeof params.protocolVersion === "string" ? params.protocolVersion : "";
57
+ return success(message.id, {
58
+ protocolVersion: VERSIONS.includes(asked) ? asked : VERSIONS[0],
59
+ capabilities: { tools: { listChanged: false } },
60
+ serverInfo: { name: options.name, version: options.version },
61
+ });
62
+ }
63
+ case "ping":
64
+ return success(message.id, {});
65
+ case "tools/list":
66
+ return success(message.id, {
67
+ tools: tools.map((tool) => ({
68
+ name: tool.name,
69
+ description: tool.description,
70
+ inputSchema: z.toJSONSchema(tool.input, { io: "input" }),
71
+ })),
72
+ });
73
+ case "tools/call": {
74
+ const tool = tools.find((entry) => entry.name === params.name);
75
+ if (!tool) return failure(message.id, -32602, `Unknown tool: ${String(params.name)}`);
76
+ try {
77
+ return success(message.id, text(await tool.run(tool.input.parse(params.arguments ?? {}), caller)));
78
+ } catch (error) {
79
+ return success(message.id, text(errorText(error), true));
80
+ }
81
+ }
82
+ default:
83
+ return failure(message.id, -32601, `Unknown method: ${message.method}`);
84
+ }
85
+ }
86
+
87
+ function readBody(request: IncomingMessage): Promise<string | null> {
88
+ return new Promise((resolve, reject) => {
89
+ const chunks: Buffer[] = [];
90
+ let size = 0;
91
+ request.on("data", (chunk: Buffer) => {
92
+ size += chunk.length;
93
+ if (size > MAX_BODY) {
94
+ resolve(null);
95
+ request.destroy();
96
+ } else chunks.push(chunk);
97
+ });
98
+ request.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
99
+ request.on("error", reject);
100
+ });
101
+ }
102
+
103
+ function send(response: ServerResponse, status: number, body?: unknown) {
104
+ response.writeHead(status, body === undefined ? {} : { "content-type": "application/json" });
105
+ response.end(body === undefined ? undefined : JSON.stringify(body));
106
+ }
107
+
108
+ // Browsers send an Origin header and agents do not, so a web page can never reach the tools.
109
+ export function createMcpServer<C>(options: McpOptions<C>): Server {
110
+ return createServer(async (request, response) => {
111
+ try {
112
+ if (new URL(request.url ?? "/", "http://localhost").pathname !== "/mcp") return send(response, 404);
113
+ if (request.headers.origin) return send(response, 403);
114
+ if (request.method !== "POST") {
115
+ response.setHeader("allow", "POST");
116
+ return send(response, 405);
117
+ }
118
+ const token = /^Bearer (\S+)$/.exec(request.headers.authorization ?? "")?.[1];
119
+ const caller = token ? await options.authorize(token) : null;
120
+ if (caller === null) return send(response, 401, failure(null, -32001, "Unknown token"));
121
+ const raw = await readBody(request);
122
+ if (raw === null) return send(response, 413);
123
+ let parsed: unknown;
124
+ try {
125
+ parsed = JSON.parse(raw);
126
+ } catch {
127
+ return send(response, 400, failure(null, -32700, "Parse error"));
128
+ }
129
+ const messages = (Array.isArray(parsed) ? parsed : [parsed]) as Message[];
130
+ const answers = (await Promise.all(messages.map((message) => answer(options, message, caller)))).filter(Boolean);
131
+ if (answers.length === 0) return send(response, 202);
132
+ send(response, 200, Array.isArray(parsed) ? answers : answers[0]);
133
+ } catch (error) {
134
+ console.error("Linear MCP request failed", error);
135
+ if (!response.headersSent) send(response, 500);
136
+ }
137
+ });
138
+ }
139
+
140
+ /** Listens on 127.0.0.1, on the preferred port when it is free. Returns the port. */
141
+ export function listenLocal(server: Server, preferred: number | null): Promise<number> {
142
+ const attempt = (port: number) =>
143
+ new Promise<number>((resolve, reject) => {
144
+ const fail = (error: Error) => reject(error);
145
+ server.once("error", fail);
146
+ server.listen(port, "127.0.0.1", () => {
147
+ server.off("error", fail);
148
+ resolve((server.address() as AddressInfo).port);
149
+ });
150
+ });
151
+ return preferred ? attempt(preferred).catch(() => attempt(0)) : attempt(0);
152
+ }
package/server/stores.ts CHANGED
@@ -13,7 +13,7 @@ interface JsonFile<T> {
13
13
  update<R>(change: (current: T) => { next: T; result: R }): Promise<R>;
14
14
  }
15
15
 
16
- function createJsonFile<T>(file: string, schema: ZodType<T>, empty: () => T): JsonFile<T> {
16
+ export function createJsonFile<T>(file: string, schema: ZodType<T>, empty: () => T): JsonFile<T> {
17
17
  let value: T | null = null;
18
18
  let queue: Promise<unknown> = Promise.resolve();
19
19
  async function load(): Promise<T> {
@@ -51,6 +51,7 @@ function createJsonFile<T>(file: string, schema: ZodType<T>, empty: () => T): Js
51
51
  export interface MapStore {
52
52
  get(identifier: string): Promise<IssueMap | null>;
53
53
  set(map: IssueMap): Promise<void>;
54
+ delete(identifier: string): Promise<void>;
54
55
  }
55
56
 
56
57
  /** Explore maps survive daemon restarts, so a finished explore run is never paid for twice. */
@@ -72,6 +73,12 @@ export function createMapStore(file: string): MapStore {
72
73
  return { next: Object.fromEntries(kept.map((entry) => [entry.identifier, entry])), result: undefined };
73
74
  });
74
75
  },
76
+ delete(identifier) {
77
+ return store.update((current) => {
78
+ const { [identifier]: _removed, ...rest } = current;
79
+ return { next: rest, result: undefined };
80
+ });
81
+ },
75
82
  };
76
83
  }
77
84