@longtable/mcp 0.1.31 → 0.1.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,7 @@
1
1
  # @longtable/mcp
2
2
 
3
- MCP transport for LongTable workspace state and Researcher Checkpoints.
3
+ MCP transport for LongTable workspace state, `$longtable-interview`, and
4
+ Researcher Checkpoints.
4
5
 
5
6
  This package does not own LongTable state. It exposes structured tools over the
6
7
  existing `.longtable/` source of truth.
@@ -14,7 +15,7 @@ longtable-state
14
15
  Run:
15
16
 
16
17
  ```bash
17
- npx -y @longtable/mcp@0.1.31
18
+ npx -y @longtable/mcp@0.1.33
18
19
  ```
19
20
 
20
21
  Self-test:
@@ -32,5 +33,14 @@ longtable mcp install --provider codex --checkpoint-ui strong --write
32
33
  If MCP elicitation is unavailable or not approved, the server returns the same
33
34
  pending `QuestionRecord` as a numbered fallback.
34
35
 
35
- Provider guidance should use `elicit_question` first when the MCP tool is
36
- available. `longtable question --print` is only the CLI fallback transport.
36
+ Provider guidance should use interview tools for `$longtable-interview`:
37
+
38
+ - `create_workspace`
39
+ - `begin_interview`
40
+ - `append_interview_turn`
41
+ - `summarize_interview`
42
+ - `confirm_first_research_shape`
43
+
44
+ For later Researcher Checkpoints, provider guidance should use
45
+ `elicit_question` first when the MCP tool is available. `longtable
46
+ question --print` is only the CLI fallback transport.
@@ -0,0 +1,25 @@
1
+ import type { QuestionOption } from "@longtable/core";
2
+ export interface FirstResearchShape {
3
+ handle: string;
4
+ currentGoal: string;
5
+ currentBlocker?: string;
6
+ researchObject?: string;
7
+ gapRisk?: string;
8
+ protectedDecision?: string;
9
+ openQuestions: string[];
10
+ nextAction: string;
11
+ confidence: "low" | "medium" | "high";
12
+ sourceHookId?: string;
13
+ confirmedAt?: string;
14
+ }
15
+ export interface FirstResearchShapeQuestionSpec {
16
+ prompt: string;
17
+ title: string;
18
+ question: string;
19
+ checkpointKey: string;
20
+ options: QuestionOption[];
21
+ displayReason: string;
22
+ }
23
+ export declare function buildFirstResearchShapeQuestion(shape: FirstResearchShape): FirstResearchShapeQuestionSpec;
24
+ export declare function firstResearchShapeAnswerConfirms(answer: string): boolean;
25
+ export declare function firstResearchShapeAnswerStatus(answer: string): "confirmed" | "active" | "deferred";
@@ -0,0 +1,111 @@
1
+ function questionTitle(shape) {
2
+ if (shape.protectedDecision) {
3
+ return "Protected Research Decision";
4
+ }
5
+ if (shape.currentBlocker) {
6
+ return "Main Unresolved Issue";
7
+ }
8
+ return "Emerging Research Direction";
9
+ }
10
+ function questionText(shape) {
11
+ if (shape.protectedDecision) {
12
+ return `Before LongTable moves forward, what should stay explicitly open about ${shape.protectedDecision}?`;
13
+ }
14
+ if (shape.currentBlocker) {
15
+ return "What should LongTable do with the main unresolved issue in this emerging study?";
16
+ }
17
+ if (shape.openQuestions.length > 0) {
18
+ return "What should LongTable keep explicit as the next research move?";
19
+ }
20
+ return "How should LongTable carry this emerging study forward?";
21
+ }
22
+ function displayReason(shape) {
23
+ if (shape.protectedDecision) {
24
+ return `The interview surfaced a protected decision: ${shape.protectedDecision}. LongTable should not let it settle silently.`;
25
+ }
26
+ if (shape.currentBlocker) {
27
+ return `The main blocker is still open: ${shape.currentBlocker}. The next move should make that uncertainty explicit.`;
28
+ }
29
+ if (shape.openQuestions.length > 0) {
30
+ return `The interview surfaced open research questions. Choose the next move that keeps the most important one visible.`;
31
+ }
32
+ return "LongTable has enough context to propose a provisional research direction, but the next research move should still be explicit.";
33
+ }
34
+ function recommendedValue(shape) {
35
+ if (shape.protectedDecision) {
36
+ return "protect_decision";
37
+ }
38
+ if (shape.confidence === "high" && !shape.currentBlocker) {
39
+ return "stabilize_shape";
40
+ }
41
+ if (shape.currentBlocker || shape.confidence === "low") {
42
+ return "gather_context";
43
+ }
44
+ return "stabilize_shape";
45
+ }
46
+ function baseOptions(shape) {
47
+ const recommended = recommendedValue(shape);
48
+ const options = [];
49
+ if (shape.protectedDecision) {
50
+ options.push({
51
+ value: "protect_decision",
52
+ label: `Keep ${shape.protectedDecision} open`,
53
+ description: "Treat this as the guarded judgment while the broader direction stays provisional.",
54
+ recommended: recommended === "protect_decision"
55
+ });
56
+ }
57
+ options.push({
58
+ value: "stabilize_shape",
59
+ label: "Use this as the provisional direction",
60
+ description: "Carry this research shape forward as the current working direction.",
61
+ recommended: recommended === "stabilize_shape"
62
+ }, {
63
+ value: "gather_context",
64
+ label: "Gather one more concrete case",
65
+ description: "Ask for one more scene, source, dataset, or example before stabilizing it.",
66
+ recommended: recommended === "gather_context"
67
+ }, {
68
+ value: "revise_shape",
69
+ label: "Revise the emerging shape",
70
+ description: "Change the handle, blocker, or next action before LongTable treats it as usable."
71
+ });
72
+ if (!shape.protectedDecision) {
73
+ options.push({
74
+ value: "keep_open",
75
+ label: "Keep the study open longer",
76
+ description: "Do not stabilize this direction yet."
77
+ });
78
+ }
79
+ return options;
80
+ }
81
+ export function buildFirstResearchShapeQuestion(shape) {
82
+ const summaryLines = [
83
+ "LongTable has enough context to propose a provisional research direction.",
84
+ `Handle: ${shape.handle}`,
85
+ `Goal: ${shape.currentGoal}`,
86
+ shape.currentBlocker ? `Blocker: ${shape.currentBlocker}` : undefined,
87
+ shape.protectedDecision ? `Protected decision: ${shape.protectedDecision}` : undefined,
88
+ shape.openQuestions.length > 0 ? `Open question: ${shape.openQuestions[0]}` : undefined,
89
+ `Next action: ${shape.nextAction}`
90
+ ].filter(Boolean);
91
+ return {
92
+ prompt: summaryLines.join("\n"),
93
+ title: questionTitle(shape),
94
+ question: questionText(shape),
95
+ checkpointKey: "first_research_shape_confirmation",
96
+ options: baseOptions(shape),
97
+ displayReason: displayReason(shape)
98
+ };
99
+ }
100
+ export function firstResearchShapeAnswerConfirms(answer) {
101
+ return answer === "stabilize_shape" || answer === "protect_decision";
102
+ }
103
+ export function firstResearchShapeAnswerStatus(answer) {
104
+ if (firstResearchShapeAnswerConfirms(answer)) {
105
+ return "confirmed";
106
+ }
107
+ if (answer === "keep_open") {
108
+ return "deferred";
109
+ }
110
+ return "active";
111
+ }
package/dist/index.d.ts CHANGED
@@ -1 +1,2 @@
1
+ export * from "./first-research-shape.js";
1
2
  export * from "./server.js";
package/dist/index.js CHANGED
@@ -1 +1,2 @@
1
+ export * from "./first-research-shape.js";
1
2
  export * from "./server.js";
package/dist/server.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { readFile, writeFile } from "node:fs/promises";
2
2
  import { existsSync } from "node:fs";
3
- import { resolve } from "node:path";
3
+ import { basename, resolve } from "node:path";
4
4
  import { cwd, exit } from "node:process";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -9,13 +9,20 @@ import { z } from "zod";
9
9
  import { classifyCheckpointTrigger } from "@longtable/checkpoints";
10
10
  import { renderQuestionRecordInput } from "@longtable/provider-claude";
11
11
  import { renderQuestionRecordPrompt } from "@longtable/provider-codex";
12
- import { answerWorkspaceQuestion, createWorkspaceQuestion, inspectProjectWorkspace, loadProjectContextFromDirectory, loadWorkspaceState, syncCurrentWorkspaceView } from "@longtable/cli";
12
+ import { loadSetupOutput } from "@longtable/setup";
13
+ import { answerWorkspaceQuestion, createOrUpdateProjectWorkspace, createWorkspaceQuestion, inspectProjectWorkspace, loadProjectContextFromDirectory, loadWorkspaceState, syncCurrentWorkspaceView } from "@longtable/cli";
14
+ import { buildFirstResearchShapeQuestion, firstResearchShapeAnswerConfirms, firstResearchShapeAnswerStatus } from "./first-research-shape.js";
13
15
  const SERVER_NAME = "longtable-state";
14
- const SERVER_VERSION = "0.1.31";
16
+ const SERVER_VERSION = "0.1.33";
15
17
  const TOOL_NAMES = [
16
18
  "read_project",
17
19
  "read_session",
18
20
  "inspect_workspace",
21
+ "create_workspace",
22
+ "begin_interview",
23
+ "append_interview_turn",
24
+ "summarize_interview",
25
+ "confirm_first_research_shape",
19
26
  "pending_questions",
20
27
  "evaluate_checkpoint",
21
28
  "create_question",
@@ -33,6 +40,19 @@ const questionOptionSchema = z.object({
33
40
  description: z.string().optional(),
34
41
  recommended: z.boolean().optional()
35
42
  });
43
+ const firstResearchShapeSchema = z.object({
44
+ handle: z.string().min(1),
45
+ currentGoal: z.string().min(1),
46
+ currentBlocker: z.string().optional(),
47
+ researchObject: z.string().optional(),
48
+ gapRisk: z.string().optional(),
49
+ protectedDecision: z.string().optional(),
50
+ openQuestions: z.array(z.string().min(1)).default([]),
51
+ nextAction: z.string().min(1),
52
+ confidence: z.enum(["low", "medium", "high"]).default("medium"),
53
+ sourceHookId: z.string().optional(),
54
+ confirmedAt: z.string().optional()
55
+ });
36
56
  function textResult(structuredContent) {
37
57
  return {
38
58
  content: [
@@ -65,6 +85,260 @@ async function requireContext(startPath) {
65
85
  }
66
86
  return context;
67
87
  }
88
+ function createId(prefix) {
89
+ return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
90
+ }
91
+ function asInterviewState(state) {
92
+ return state;
93
+ }
94
+ function isInterviewHookRun(hook) {
95
+ return hook?.kind === "longtable_interview";
96
+ }
97
+ function activeInterviewHook(state, hookId) {
98
+ const hooks = state.hooks ?? [];
99
+ if (hookId) {
100
+ const hook = hooks.find((candidate) => candidate.id === hookId);
101
+ return isInterviewHookRun(hook) ? hook : undefined;
102
+ }
103
+ return [...hooks].reverse().find((hook) => hook.kind === "longtable_interview" &&
104
+ (hook.status === "pending" || hook.status === "active" || hook.status === "ready_to_confirm"));
105
+ }
106
+ function upsertInterviewHook(state, hook) {
107
+ const hooks = state.hooks ?? [];
108
+ const nextHooks = hooks.some((candidate) => candidate.id === hook.id)
109
+ ? hooks.map((candidate) => candidate.id === hook.id ? hook : candidate)
110
+ : [...hooks, hook];
111
+ return {
112
+ ...state,
113
+ hooks: nextHooks
114
+ };
115
+ }
116
+ function upsertQuestionObligation(state, obligation) {
117
+ const current = state.questionObligations ?? [];
118
+ const next = current.some((entry) => entry.id === obligation.id)
119
+ ? current.map((entry) => entry.id === obligation.id ? obligation : entry)
120
+ : [...current, obligation];
121
+ return {
122
+ ...state,
123
+ questionObligations: next
124
+ };
125
+ }
126
+ function ensureFirstResearchShapeObligation(state, shape, options) {
127
+ const existing = (state.questionObligations ?? []).find((obligation) => obligation.kind === "first_research_shape_confirmation" &&
128
+ obligation.status === "pending" &&
129
+ obligation.sourceHookId === shape.sourceHookId);
130
+ if (!existing) {
131
+ return upsertQuestionObligation(state, {
132
+ id: createId("question_obligation"),
133
+ kind: "first_research_shape_confirmation",
134
+ status: "pending",
135
+ createdAt: new Date().toISOString(),
136
+ updatedAt: new Date().toISOString(),
137
+ prompt: options.prompt,
138
+ reason: options.reason,
139
+ ...(shape.sourceHookId ? { sourceHookId: shape.sourceHookId } : {}),
140
+ ...(options.questionId ? { questionId: options.questionId } : {})
141
+ });
142
+ }
143
+ return upsertQuestionObligation(state, {
144
+ ...existing,
145
+ updatedAt: new Date().toISOString(),
146
+ prompt: options.prompt,
147
+ reason: options.reason,
148
+ ...(options.questionId ? { questionId: options.questionId } : existing.questionId ? { questionId: existing.questionId } : {}),
149
+ ...(shape.sourceHookId ? { sourceHookId: shape.sourceHookId } : {})
150
+ });
151
+ }
152
+ function resolveFirstResearchShapeObligation(state, options) {
153
+ return {
154
+ ...state,
155
+ questionObligations: (state.questionObligations ?? []).map((obligation) => {
156
+ const matches = obligation.kind === "first_research_shape_confirmation" && ((options.sourceHookId && obligation.sourceHookId === options.sourceHookId) ||
157
+ (options.questionId && obligation.questionId === options.questionId));
158
+ if (!matches || obligation.status !== "pending") {
159
+ return obligation;
160
+ }
161
+ return {
162
+ ...obligation,
163
+ status: options.status ?? "satisfied",
164
+ updatedAt: new Date().toISOString(),
165
+ ...(options.questionId ? { questionId: options.questionId } : obligation.questionId ? { questionId: obligation.questionId } : {}),
166
+ ...(options.decisionId ? { decisionId: options.decisionId } : obligation.decisionId ? { decisionId: obligation.decisionId } : {})
167
+ };
168
+ })
169
+ };
170
+ }
171
+ function interviewDepth(turns) {
172
+ const usableTurns = turns.filter((turn) => turn.quality !== "thin").length;
173
+ if (usableTurns >= 3)
174
+ return "ready_to_summarize";
175
+ if (usableTurns >= 1)
176
+ return "forming_first_handle";
177
+ return "gathering_context";
178
+ }
179
+ function normalizeInterviewQuality(answer, quality) {
180
+ if (quality)
181
+ return quality;
182
+ const trimmed = answer.trim();
183
+ const wordCount = trimmed.split(/\s+/).filter(Boolean).length;
184
+ if (trimmed.length < 12 || wordCount < 3)
185
+ return "thin";
186
+ if (trimmed.length > 80 || wordCount >= 12)
187
+ return "rich";
188
+ return "usable";
189
+ }
190
+ function defaultFollowUpQuestion(answer) {
191
+ return answer.trim().length < 12
192
+ ? "Say one more sentence about where this problem appears or why it matters before LongTable tries to classify it."
193
+ : "What concrete scene, case, material, text, dataset, or decision would make that problem easier to inspect first?";
194
+ }
195
+ async function beginInterviewHook(context, options) {
196
+ const state = asInterviewState(await loadWorkspaceState(context));
197
+ const existing = activeInterviewHook(state);
198
+ if (existing) {
199
+ return { hook: existing, state };
200
+ }
201
+ const timestamp = new Date().toISOString();
202
+ const hook = {
203
+ id: createId("hook_interview"),
204
+ kind: "longtable_interview",
205
+ status: "active",
206
+ createdAt: timestamp,
207
+ updatedAt: timestamp,
208
+ targetOutcome: "first_research_handle",
209
+ depth: "gathering_context",
210
+ provider: options.provider,
211
+ turns: [],
212
+ qualityNotes: [],
213
+ rationale: [
214
+ "Official LongTable research start surface is provider-native `$longtable-interview`, not the CLI start questionnaire."
215
+ ]
216
+ };
217
+ const updated = upsertInterviewHook(state, hook);
218
+ updated.workingState = {
219
+ ...updated.workingState,
220
+ activeInterviewHookId: hook.id,
221
+ interviewSurface: "$longtable-interview",
222
+ ...(options.openingQuestion ? { interviewOpeningQuestion: options.openingQuestion } : {}),
223
+ ...(options.seedAnswer ? { interviewSeedAnswer: options.seedAnswer } : {})
224
+ };
225
+ await writeFile(context.stateFilePath, JSON.stringify(updated, null, 2), "utf8");
226
+ await syncCurrentWorkspaceView(context);
227
+ return { hook, state: updated };
228
+ }
229
+ async function appendInterviewTurn(context, options) {
230
+ const state = asInterviewState(await loadWorkspaceState(context));
231
+ const existing = activeInterviewHook(state, options.hookId);
232
+ if (!existing) {
233
+ throw new Error("No active LongTable interview hook was found. Run begin_interview first.");
234
+ }
235
+ const quality = normalizeInterviewQuality(options.answer, options.quality);
236
+ const needsFollowUp = options.needsFollowUp ?? quality === "thin";
237
+ const followUpQuestion = needsFollowUp
238
+ ? options.followUpQuestion ?? defaultFollowUpQuestion(options.answer)
239
+ : options.followUpQuestion;
240
+ const timestamp = new Date().toISOString();
241
+ const turn = {
242
+ id: createId("interview_turn"),
243
+ index: existing.turns.length + 1,
244
+ createdAt: timestamp,
245
+ question: options.question.trim(),
246
+ answer: options.answer.trim(),
247
+ ...(options.reflection?.trim() ? { reflection: options.reflection.trim() } : {}),
248
+ quality,
249
+ needsFollowUp,
250
+ ...(followUpQuestion?.trim() ? { followUpQuestion: followUpQuestion.trim() } : {}),
251
+ ...(options.rationale && options.rationale.length > 0 ? { rationale: options.rationale } : {})
252
+ };
253
+ const turns = [...existing.turns, turn];
254
+ const depth = interviewDepth(turns);
255
+ const hook = {
256
+ ...existing,
257
+ status: depth === "ready_to_summarize" ? "ready_to_confirm" : "active",
258
+ updatedAt: timestamp,
259
+ depth,
260
+ turns,
261
+ qualityNotes: [
262
+ ...existing.qualityNotes,
263
+ ...(needsFollowUp ? [`Turn ${turn.index} needs follow-up: ${followUpQuestion}`] : [])
264
+ ]
265
+ };
266
+ const updated = upsertInterviewHook(state, hook);
267
+ await writeFile(context.stateFilePath, JSON.stringify(updated, null, 2), "utf8");
268
+ await syncCurrentWorkspaceView(context);
269
+ return { hook, turn, state: updated };
270
+ }
271
+ async function summarizeInterviewHook(context, options) {
272
+ const state = asInterviewState(await loadWorkspaceState(context));
273
+ const existing = activeInterviewHook(state, options.hookId);
274
+ if (!existing) {
275
+ throw new Error("No active LongTable interview hook was found. Run begin_interview first.");
276
+ }
277
+ const timestamp = new Date().toISOString();
278
+ const shape = {
279
+ ...options.shape,
280
+ handle: options.shape.handle.trim(),
281
+ currentGoal: options.shape.currentGoal.trim(),
282
+ openQuestions: options.shape.openQuestions.map((question) => question.trim()).filter(Boolean),
283
+ nextAction: options.shape.nextAction.trim(),
284
+ sourceHookId: existing.id
285
+ };
286
+ const hook = {
287
+ ...existing,
288
+ status: "ready_to_confirm",
289
+ updatedAt: timestamp,
290
+ depth: "ready_to_summarize",
291
+ firstResearchShape: shape
292
+ };
293
+ const session = {
294
+ ...context.session,
295
+ lastUpdatedAt: timestamp,
296
+ currentGoal: shape.currentGoal,
297
+ ...(shape.currentBlocker ? { currentBlocker: shape.currentBlocker } : {}),
298
+ ...(shape.researchObject ? { researchObject: shape.researchObject } : {}),
299
+ ...(shape.gapRisk ? { gapRisk: shape.gapRisk } : {}),
300
+ ...(shape.protectedDecision ? { protectedDecision: shape.protectedDecision } : {}),
301
+ nextAction: shape.nextAction,
302
+ openQuestions: shape.openQuestions,
303
+ firstResearchShape: shape,
304
+ resumeHint: `I want to continue from the First Research Shape: ${shape.handle}.`
305
+ };
306
+ context.session = session;
307
+ const updated = upsertInterviewHook(state, hook);
308
+ updated.firstResearchShape = shape;
309
+ updated.workingState = {
310
+ ...updated.workingState,
311
+ currentGoal: shape.currentGoal,
312
+ ...(shape.currentBlocker ? { currentBlocker: shape.currentBlocker } : {}),
313
+ ...(shape.researchObject ? { researchObject: shape.researchObject } : {}),
314
+ ...(shape.gapRisk ? { gapRisk: shape.gapRisk } : {}),
315
+ ...(shape.protectedDecision ? { protectedDecision: shape.protectedDecision } : {}),
316
+ openQuestions: shape.openQuestions,
317
+ nextAction: shape.nextAction,
318
+ firstResearchShape: shape
319
+ };
320
+ if (shape.currentBlocker && !updated.openTensions.includes(shape.currentBlocker)) {
321
+ updated.openTensions.push(shape.currentBlocker);
322
+ }
323
+ updated.narrativeTraces.push({
324
+ id: createId("narrative_trace"),
325
+ timestamp,
326
+ source: "$longtable-interview",
327
+ traceType: "judgment",
328
+ summary: `First Research Shape: ${shape.handle}.`,
329
+ visibility: "explicit",
330
+ importance: shape.confidence
331
+ });
332
+ const questionSpec = buildFirstResearchShapeQuestion(shape);
333
+ const withObligation = ensureFirstResearchShapeObligation(updated, shape, {
334
+ prompt: questionSpec.question,
335
+ reason: questionSpec.displayReason
336
+ });
337
+ await writeFile(context.sessionFilePath, JSON.stringify(session, null, 2), "utf8");
338
+ await writeFile(context.stateFilePath, JSON.stringify(withObligation, null, 2), "utf8");
339
+ await syncCurrentWorkspaceView(context);
340
+ return { hook, shape, state: withObligation, session };
341
+ }
68
342
  function findQuestion(records, questionId) {
69
343
  if (questionId) {
70
344
  return records.find((record) => record.id === questionId) ?? null;
@@ -77,7 +351,7 @@ function renderQuestionFallback(record, provider = "codex") {
77
351
  : renderQuestionRecordPrompt(record);
78
352
  }
79
353
  async function markQuestionTransport(context, questionId, status, message) {
80
- const state = await loadWorkspaceState(context);
354
+ const state = asInterviewState(await loadWorkspaceState(context));
81
355
  let updatedQuestion = null;
82
356
  state.questionLog = (state.questionLog ?? []).map((record) => {
83
357
  if (record.id !== questionId) {
@@ -148,6 +422,51 @@ function acceptedAnswer(result) {
148
422
  answer
149
423
  };
150
424
  }
425
+ async function markFirstResearchShapeConfirmation(context, shape, answer, questionId, decisionId) {
426
+ const state = asInterviewState(await loadWorkspaceState(context));
427
+ const timestamp = new Date().toISOString();
428
+ const confirmedShape = firstResearchShapeAnswerConfirms(answer)
429
+ ? { ...shape, confirmedAt: timestamp }
430
+ : shape;
431
+ state.firstResearchShape = confirmedShape;
432
+ state.workingState = {
433
+ ...state.workingState,
434
+ firstResearchShape: confirmedShape
435
+ };
436
+ state.hooks = (state.hooks ?? []).map((hook) => {
437
+ if (hook.id !== shape.sourceHookId || !isInterviewHookRun(hook)) {
438
+ return hook;
439
+ }
440
+ return {
441
+ ...hook,
442
+ status: firstResearchShapeAnswerStatus(answer),
443
+ updatedAt: timestamp,
444
+ firstResearchShape: confirmedShape,
445
+ linkedQuestionRecordIds: questionId
446
+ ? [...(hook.linkedQuestionRecordIds ?? []), questionId]
447
+ : hook.linkedQuestionRecordIds,
448
+ linkedDecisionRecordIds: decisionId
449
+ ? [...(hook.linkedDecisionRecordIds ?? []), decisionId]
450
+ : hook.linkedDecisionRecordIds
451
+ };
452
+ });
453
+ const nextState = resolveFirstResearchShapeObligation(state, {
454
+ sourceHookId: shape.sourceHookId,
455
+ questionId,
456
+ decisionId,
457
+ status: "satisfied"
458
+ });
459
+ const session = {
460
+ ...context.session,
461
+ firstResearchShape: confirmedShape,
462
+ lastUpdatedAt: timestamp
463
+ };
464
+ context.session = session;
465
+ await writeFile(context.sessionFilePath, JSON.stringify(session, null, 2), "utf8");
466
+ await writeFile(context.stateFilePath, JSON.stringify(nextState, null, 2), "utf8");
467
+ await syncCurrentWorkspaceView(context);
468
+ return { state: nextState, session, shape: confirmedShape };
469
+ }
151
470
  function statusForElicitationError(error) {
152
471
  const message = error instanceof Error ? error.message : String(error);
153
472
  if (/timed?\s*out|timeout/i.test(message)) {
@@ -238,6 +557,221 @@ export function createLongTableMcpServer() {
238
557
  return errorResult(error instanceof Error ? error.message : String(error));
239
558
  }
240
559
  });
560
+ server.registerTool("create_workspace", {
561
+ title: "Create LongTable Workspace",
562
+ description: "Create a .longtable workspace in the current folder for provider-native $longtable-interview.",
563
+ inputSchema: cwdSchema.extend({
564
+ projectName: z.string().optional(),
565
+ projectPath: z.string().optional(),
566
+ seedGoal: z.string().optional(),
567
+ setupPath: z.string().optional(),
568
+ provider: z.enum(["codex", "claude"]).default("codex")
569
+ })
570
+ }, async ({ cwd: inputCwd, projectName, projectPath, seedGoal, setupPath, provider }) => {
571
+ try {
572
+ const targetPath = resolveStartPath(projectPath ?? inputCwd);
573
+ const setup = await loadSetupOutput(setupPath);
574
+ const context = await createOrUpdateProjectWorkspace({
575
+ projectName: projectName?.trim() || basename(targetPath) || "LongTable Research",
576
+ projectPath: targetPath,
577
+ currentGoal: seedGoal?.trim() || "First research handle pending",
578
+ requestedPerspectives: [],
579
+ disagreementPreference: setup.profileSeed.panelPreference ?? "show_on_conflict",
580
+ setup
581
+ });
582
+ const interview = await beginInterviewHook(context, {
583
+ provider: provider,
584
+ openingQuestion: "What do you want to research? If it is not clear yet, describe the problem in its rough form.",
585
+ seedAnswer: seedGoal
586
+ });
587
+ return textResult({
588
+ project: context.project,
589
+ session: context.session,
590
+ hook: interview.hook,
591
+ files: {
592
+ project: context.projectFilePath,
593
+ session: context.sessionFilePath,
594
+ state: context.stateFilePath,
595
+ current: context.currentFilePath
596
+ },
597
+ nextQuestion: "What do you want to research? If it is not clear yet, describe the problem in its rough form."
598
+ });
599
+ }
600
+ catch (error) {
601
+ return errorResult(error instanceof Error ? error.message : String(error));
602
+ }
603
+ });
604
+ server.registerTool("begin_interview", {
605
+ title: "Begin LongTable Interview",
606
+ description: "Create or resume the active $longtable-interview hook in an existing workspace.",
607
+ inputSchema: cwdSchema.extend({
608
+ openingQuestion: z.string().optional(),
609
+ seedAnswer: z.string().optional(),
610
+ provider: z.enum(["codex", "claude"]).default("codex")
611
+ })
612
+ }, async ({ cwd: inputCwd, openingQuestion, seedAnswer, provider }) => {
613
+ try {
614
+ const context = await requireContext(inputCwd);
615
+ const result = await beginInterviewHook(context, {
616
+ provider: provider,
617
+ openingQuestion,
618
+ seedAnswer
619
+ });
620
+ return textResult(result);
621
+ }
622
+ catch (error) {
623
+ return errorResult(error instanceof Error ? error.message : String(error));
624
+ }
625
+ });
626
+ server.registerTool("append_interview_turn", {
627
+ title: "Append LongTable Interview Turn",
628
+ description: "Record one natural-language interview question and answer, including quality/follow-up metadata.",
629
+ inputSchema: cwdSchema.extend({
630
+ hookId: z.string().optional(),
631
+ question: z.string().min(1),
632
+ answer: z.string().min(1),
633
+ reflection: z.string().optional(),
634
+ quality: z.enum(["thin", "usable", "rich"]).optional(),
635
+ needsFollowUp: z.boolean().optional(),
636
+ followUpQuestion: z.string().optional(),
637
+ rationale: z.array(z.string()).optional()
638
+ })
639
+ }, async ({ cwd: inputCwd, hookId, question, answer, reflection, quality, needsFollowUp, followUpQuestion, rationale }) => {
640
+ try {
641
+ const context = await requireContext(inputCwd);
642
+ const result = await appendInterviewTurn(context, {
643
+ hookId,
644
+ question,
645
+ answer,
646
+ reflection,
647
+ quality: quality,
648
+ needsFollowUp,
649
+ followUpQuestion,
650
+ rationale
651
+ });
652
+ return textResult(result);
653
+ }
654
+ catch (error) {
655
+ return errorResult(error instanceof Error ? error.message : String(error));
656
+ }
657
+ });
658
+ server.registerTool("summarize_interview", {
659
+ title: "Summarize LongTable Interview",
660
+ description: "Store the provisional First Research Shape after enough interview context has accumulated.",
661
+ inputSchema: cwdSchema.extend({
662
+ hookId: z.string().optional(),
663
+ shape: firstResearchShapeSchema
664
+ })
665
+ }, async ({ cwd: inputCwd, hookId, shape }) => {
666
+ try {
667
+ const context = await requireContext(inputCwd);
668
+ const result = await summarizeInterviewHook(context, {
669
+ hookId,
670
+ shape: shape
671
+ });
672
+ return textResult(result);
673
+ }
674
+ catch (error) {
675
+ return errorResult(error instanceof Error ? error.message : String(error));
676
+ }
677
+ });
678
+ server.registerTool("confirm_first_research_shape", {
679
+ title: "Confirm First Research Shape",
680
+ description: "Use MCP form elicitation to confirm, revise, defer, or request more context for the First Research Shape.",
681
+ inputSchema: cwdSchema.extend({
682
+ shape: firstResearchShapeSchema.optional(),
683
+ provider: z.enum(["codex", "claude"]).default("codex"),
684
+ fallbackOnly: z.boolean().default(false)
685
+ })
686
+ }, async ({ cwd: inputCwd, shape: inputShape, provider, fallbackOnly }) => {
687
+ try {
688
+ const context = await requireContext(inputCwd);
689
+ const state = asInterviewState(await loadWorkspaceState(context));
690
+ const shape = inputShape ?? state.firstResearchShape;
691
+ if (!shape) {
692
+ return errorResult("No First Research Shape was found to confirm. Run summarize_interview first.");
693
+ }
694
+ const spec = buildFirstResearchShapeQuestion(shape);
695
+ const created = await createWorkspaceQuestion({
696
+ context,
697
+ prompt: spec.prompt,
698
+ title: spec.title,
699
+ question: spec.question,
700
+ checkpointKey: spec.checkpointKey,
701
+ questionOptions: spec.options,
702
+ displayReason: spec.displayReason,
703
+ provider: provider,
704
+ required: true
705
+ });
706
+ const createdState = ensureFirstResearchShapeObligation(asInterviewState(await loadWorkspaceState(context)), shape, {
707
+ prompt: spec.question,
708
+ reason: spec.displayReason,
709
+ questionId: created.question.id
710
+ });
711
+ await writeFile(context.stateFilePath, JSON.stringify(createdState, null, 2), "utf8");
712
+ await syncCurrentWorkspaceView(context);
713
+ const fallback = renderQuestionFallback(created.question, provider);
714
+ if (fallbackOnly) {
715
+ const marked = await markQuestionTransport(context, created.question.id, "fallback_rendered", "MCP elicitation skipped by fallbackOnly.");
716
+ return textResult({
717
+ question: marked ?? created.question,
718
+ shape,
719
+ elicitation: { attempted: false, reason: "fallbackOnly" },
720
+ fallback
721
+ });
722
+ }
723
+ try {
724
+ await markQuestionTransport(context, created.question.id, "attempted");
725
+ const elicited = await server.server.elicitInput(buildElicitationParams(created.question));
726
+ const accepted = acceptedAnswer(elicited);
727
+ if (!accepted) {
728
+ const status = elicited.action === "decline" || elicited.action === "cancel"
729
+ ? "declined"
730
+ : "fallback_rendered";
731
+ const marked = await markQuestionTransport(context, created.question.id, status, `MCP elicitation returned action: ${elicited.action}.`);
732
+ return textResult({
733
+ question: marked ?? created.question,
734
+ shape,
735
+ elicitation: { attempted: true, action: elicited.action },
736
+ fallback
737
+ });
738
+ }
739
+ const decided = await answerWorkspaceQuestion({
740
+ context,
741
+ questionId: created.question.id,
742
+ answer: accepted.answer,
743
+ provider: provider,
744
+ surface: "mcp_elicitation"
745
+ });
746
+ const marked = await markQuestionTransport(context, created.question.id, "accepted");
747
+ const confirmation = await markFirstResearchShapeConfirmation(context, shape, accepted.answer, created.question.id, decided.decision.id);
748
+ return textResult({
749
+ shape: confirmation.shape,
750
+ question: marked ? { ...decided.question, transportStatus: marked.transportStatus } : decided.question,
751
+ decision: decided.decision,
752
+ elicitation: { attempted: true, action: elicited.action }
753
+ });
754
+ }
755
+ catch (elicitationError) {
756
+ const status = statusForElicitationError(elicitationError);
757
+ const message = elicitationError instanceof Error ? elicitationError.message : String(elicitationError);
758
+ const marked = await markQuestionTransport(context, created.question.id, status, message);
759
+ return textResult({
760
+ question: marked ?? created.question,
761
+ shape,
762
+ elicitation: {
763
+ attempted: true,
764
+ supported: status !== "unsupported" ? undefined : false,
765
+ error: message
766
+ },
767
+ fallback
768
+ });
769
+ }
770
+ }
771
+ catch (error) {
772
+ return errorResult(error instanceof Error ? error.message : String(error));
773
+ }
774
+ });
241
775
  server.registerTool("pending_questions", {
242
776
  title: "List Pending Researcher Checkpoints",
243
777
  description: "List pending LongTable QuestionRecords.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@longtable/mcp",
3
- "version": "0.1.31",
3
+ "version": "0.1.33",
4
4
  "private": false,
5
5
  "description": "LongTable MCP transport for workspace state and Researcher Checkpoints",
6
6
  "type": "module",
@@ -26,11 +26,12 @@
26
26
  "self-test": "node ./dist/server.js --self-test"
27
27
  },
28
28
  "dependencies": {
29
- "@longtable/checkpoints": "0.1.31",
30
- "@longtable/cli": "0.1.31",
31
- "@longtable/core": "0.1.31",
32
- "@longtable/provider-claude": "0.1.31",
33
- "@longtable/provider-codex": "0.1.31",
29
+ "@longtable/checkpoints": "0.1.33",
30
+ "@longtable/cli": "0.1.33",
31
+ "@longtable/core": "0.1.33",
32
+ "@longtable/provider-claude": "0.1.33",
33
+ "@longtable/provider-codex": "0.1.33",
34
+ "@longtable/setup": "0.1.33",
34
35
  "@modelcontextprotocol/sdk": "^1.29.0",
35
36
  "zod": "^4.0.0"
36
37
  },