@aetherwing/fcp-core 0.1.0
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/event-log.d.ts +78 -0
- package/dist/event-log.d.ts.map +1 -0
- package/dist/event-log.js +184 -0
- package/dist/event-log.js.map +1 -0
- package/dist/formatter.d.ts +19 -0
- package/dist/formatter.d.ts.map +1 -0
- package/dist/formatter.js +64 -0
- package/dist/formatter.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/parsed-op.d.ts +32 -0
- package/dist/parsed-op.d.ts.map +1 -0
- package/dist/parsed-op.js +41 -0
- package/dist/parsed-op.js.map +1 -0
- package/dist/server.d.ts +71 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +140 -0
- package/dist/server.js.map +1 -0
- package/dist/session.d.ts +40 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +142 -0
- package/dist/session.js.map +1 -0
- package/dist/tokenizer.d.ts +26 -0
- package/dist/tokenizer.d.ts.map +1 -0
- package/dist/tokenizer.js +114 -0
- package/dist/tokenizer.js.map +1 -0
- package/dist/verb-registry.d.ts +41 -0
- package/dist/verb-registry.d.ts.map +1 -0
- package/dist/verb-registry.js +65 -0
- package/dist/verb-registry.js.map +1 -0
- package/package.json +30 -0
- package/src/event-log.ts +209 -0
- package/src/formatter.ts +70 -0
- package/src/index.ts +40 -0
- package/src/parsed-op.ts +64 -0
- package/src/server.ts +241 -0
- package/src/session.ts +163 -0
- package/src/tokenizer.ts +108 -0
- package/src/verb-registry.ts +84 -0
- package/tests/event-log.test.ts +177 -0
- package/tests/formatter.test.ts +61 -0
- package/tests/parsed-op.test.ts +95 -0
- package/tests/server.test.ts +94 -0
- package/tests/session.test.ts +210 -0
- package/tests/tokenizer.test.ts +144 -0
- package/tests/verb-registry.test.ts +76 -0
- package/tsconfig.json +19 -0
- package/vitest.config.ts +7 -0
package/src/event-log.ts
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sentinel object stored in the event log to mark a checkpoint.
|
|
3
|
+
*/
|
|
4
|
+
const CHECKPOINT_SENTINEL = Symbol("checkpoint");
|
|
5
|
+
|
|
6
|
+
interface CheckpointEntry {
|
|
7
|
+
__type: typeof CHECKPOINT_SENTINEL;
|
|
8
|
+
name: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function isCheckpoint<T>(entry: T | CheckpointEntry): entry is CheckpointEntry {
|
|
12
|
+
return (
|
|
13
|
+
typeof entry === "object" &&
|
|
14
|
+
entry !== null &&
|
|
15
|
+
"__type" in entry &&
|
|
16
|
+
(entry as CheckpointEntry).__type === CHECKPOINT_SENTINEL
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Generic cursor-based event log with undo/redo and named checkpoints.
|
|
22
|
+
*
|
|
23
|
+
* Events are appended at the cursor position. The cursor always points
|
|
24
|
+
* one past the last applied event. Undo moves the cursor back; redo
|
|
25
|
+
* moves it forward. Appending a new event truncates the redo tail.
|
|
26
|
+
*
|
|
27
|
+
* Checkpoint sentinels are stored in the log but skipped during
|
|
28
|
+
* undo/redo traversal.
|
|
29
|
+
*/
|
|
30
|
+
export class EventLog<T> {
|
|
31
|
+
private events: Array<T | CheckpointEntry> = [];
|
|
32
|
+
private _cursor = 0;
|
|
33
|
+
private checkpoints = new Map<string, number>();
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Append an event, truncating any redo history beyond the cursor.
|
|
37
|
+
*/
|
|
38
|
+
append(event: T): void {
|
|
39
|
+
if (this._cursor < this.events.length) {
|
|
40
|
+
this.events.length = this._cursor;
|
|
41
|
+
// Remove checkpoints pointing beyond new length
|
|
42
|
+
for (const [name, idx] of this.checkpoints) {
|
|
43
|
+
if (idx > this._cursor) {
|
|
44
|
+
this.checkpoints.delete(name);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
this.events.push(event);
|
|
49
|
+
this._cursor = this.events.length;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Create a named checkpoint at the current cursor position.
|
|
54
|
+
*/
|
|
55
|
+
checkpoint(name: string): void {
|
|
56
|
+
this.checkpoints.set(name, this._cursor);
|
|
57
|
+
const sentinel: CheckpointEntry = { __type: CHECKPOINT_SENTINEL, name };
|
|
58
|
+
this.events.push(sentinel);
|
|
59
|
+
this._cursor = this.events.length;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Undo up to `count` non-checkpoint events. Returns events in reverse
|
|
64
|
+
* order (most recent first) for the caller to reverse-apply.
|
|
65
|
+
*/
|
|
66
|
+
undo(count: number = 1): T[] {
|
|
67
|
+
const result: T[] = [];
|
|
68
|
+
let pos = this._cursor - 1;
|
|
69
|
+
let undone = 0;
|
|
70
|
+
|
|
71
|
+
while (pos >= 0 && undone < count) {
|
|
72
|
+
const entry = this.events[pos];
|
|
73
|
+
if (!isCheckpoint(entry)) {
|
|
74
|
+
result.push(entry);
|
|
75
|
+
undone++;
|
|
76
|
+
}
|
|
77
|
+
pos--;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
this._cursor = pos + 1;
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Undo to a named checkpoint. Returns events in reverse order.
|
|
86
|
+
* Returns null if the checkpoint doesn't exist or is at/beyond cursor.
|
|
87
|
+
*/
|
|
88
|
+
undoTo(name: string): T[] | null {
|
|
89
|
+
const target = this.checkpoints.get(name);
|
|
90
|
+
if (target === undefined || target >= this._cursor) return null;
|
|
91
|
+
|
|
92
|
+
const result: T[] = [];
|
|
93
|
+
for (let i = this._cursor - 1; i >= target; i--) {
|
|
94
|
+
const entry = this.events[i];
|
|
95
|
+
if (!isCheckpoint(entry)) {
|
|
96
|
+
result.push(entry);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
this._cursor = target;
|
|
100
|
+
return result;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Redo up to `count` non-checkpoint events. Returns events in forward
|
|
105
|
+
* order for the caller to re-apply.
|
|
106
|
+
*/
|
|
107
|
+
redo(count: number = 1): T[] {
|
|
108
|
+
const result: T[] = [];
|
|
109
|
+
let pos = this._cursor;
|
|
110
|
+
let redone = 0;
|
|
111
|
+
|
|
112
|
+
while (pos < this.events.length && redone < count) {
|
|
113
|
+
const entry = this.events[pos];
|
|
114
|
+
if (!isCheckpoint(entry)) {
|
|
115
|
+
result.push(entry);
|
|
116
|
+
redone++;
|
|
117
|
+
}
|
|
118
|
+
pos++;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
this._cursor = pos;
|
|
122
|
+
return result;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Get the last N non-checkpoint events (up to cursor). Returned in
|
|
127
|
+
* chronological order (oldest first).
|
|
128
|
+
*/
|
|
129
|
+
recent(count?: number): T[] {
|
|
130
|
+
const limit = count ?? this._cursor;
|
|
131
|
+
const result: T[] = [];
|
|
132
|
+
for (let i = this._cursor - 1; i >= 0 && result.length < limit; i--) {
|
|
133
|
+
const entry = this.events[i];
|
|
134
|
+
if (!isCheckpoint(entry)) {
|
|
135
|
+
result.push(entry);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return result.reverse();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Current cursor position (one past last applied event).
|
|
143
|
+
*/
|
|
144
|
+
get cursor(): number {
|
|
145
|
+
return this._cursor;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Total number of entries in the log (including checkpoints).
|
|
150
|
+
*/
|
|
151
|
+
get length(): number {
|
|
152
|
+
return this.events.length;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Whether there are events before the cursor that can be undone.
|
|
157
|
+
*/
|
|
158
|
+
canUndo(): boolean {
|
|
159
|
+
for (let i = this._cursor - 1; i >= 0; i--) {
|
|
160
|
+
if (!isCheckpoint(this.events[i])) return true;
|
|
161
|
+
}
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Whether there are events after the cursor that can be redone.
|
|
167
|
+
*/
|
|
168
|
+
canRedo(): boolean {
|
|
169
|
+
return this._cursor < this.events.length;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Get the event index for a named checkpoint.
|
|
174
|
+
* Returns undefined if the checkpoint doesn't exist.
|
|
175
|
+
*/
|
|
176
|
+
getCheckpointIndex(name: string): number | undefined {
|
|
177
|
+
return this.checkpoints.get(name);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Number of named checkpoints.
|
|
182
|
+
*/
|
|
183
|
+
get checkpointCount(): number {
|
|
184
|
+
return this.checkpoints.size;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Get all checkpoint names and their indices.
|
|
189
|
+
*/
|
|
190
|
+
getCheckpoints(): Map<string, number> {
|
|
191
|
+
return new Map(this.checkpoints);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Get non-checkpoint events from a given index to the cursor.
|
|
196
|
+
* Used for diff queries.
|
|
197
|
+
*/
|
|
198
|
+
eventsSince(fromIndex: number): T[] {
|
|
199
|
+
const result: T[] = [];
|
|
200
|
+
const end = Math.min(this._cursor, this.events.length);
|
|
201
|
+
for (let i = fromIndex; i < end; i++) {
|
|
202
|
+
const entry = this.events[i];
|
|
203
|
+
if (!isCheckpoint(entry)) {
|
|
204
|
+
result.push(entry);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return result;
|
|
208
|
+
}
|
|
209
|
+
}
|
package/src/formatter.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Format a result message with a prefix character.
|
|
3
|
+
*
|
|
4
|
+
* Prefix conventions:
|
|
5
|
+
* + created
|
|
6
|
+
* ~ modified (edge/connection)
|
|
7
|
+
* * changed (property)
|
|
8
|
+
* - removed
|
|
9
|
+
* ! meta/group operation
|
|
10
|
+
* @ bulk/layout operation
|
|
11
|
+
*/
|
|
12
|
+
export function formatResult(
|
|
13
|
+
success: boolean,
|
|
14
|
+
message: string,
|
|
15
|
+
prefix?: string,
|
|
16
|
+
): string {
|
|
17
|
+
if (!success) return `ERROR: ${message}`;
|
|
18
|
+
if (prefix) return `${prefix} ${message}`;
|
|
19
|
+
return message;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Suggest a correction for a misspelled input by finding the closest
|
|
24
|
+
* candidate using Levenshtein distance. Returns null if no candidate
|
|
25
|
+
* is close enough (distance > 3).
|
|
26
|
+
*/
|
|
27
|
+
export function suggest(input: string, candidates: string[]): string | null {
|
|
28
|
+
if (candidates.length === 0) return null;
|
|
29
|
+
|
|
30
|
+
let best: string | null = null;
|
|
31
|
+
let bestDist = Infinity;
|
|
32
|
+
|
|
33
|
+
for (const candidate of candidates) {
|
|
34
|
+
const dist = levenshtein(input.toLowerCase(), candidate.toLowerCase());
|
|
35
|
+
if (dist < bestDist) {
|
|
36
|
+
bestDist = dist;
|
|
37
|
+
best = candidate;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return bestDist <= 3 ? best : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Compute Levenshtein distance between two strings.
|
|
46
|
+
*/
|
|
47
|
+
function levenshtein(a: string, b: string): number {
|
|
48
|
+
const m = a.length;
|
|
49
|
+
const n = b.length;
|
|
50
|
+
|
|
51
|
+
// Use a single-row DP array
|
|
52
|
+
const prev = new Array<number>(n + 1);
|
|
53
|
+
for (let j = 0; j <= n; j++) prev[j] = j;
|
|
54
|
+
|
|
55
|
+
for (let i = 1; i <= m; i++) {
|
|
56
|
+
let prevDiag = prev[0];
|
|
57
|
+
prev[0] = i;
|
|
58
|
+
for (let j = 1; j <= n; j++) {
|
|
59
|
+
const temp = prev[j];
|
|
60
|
+
if (a[i - 1] === b[j - 1]) {
|
|
61
|
+
prev[j] = prevDiag;
|
|
62
|
+
} else {
|
|
63
|
+
prev[j] = 1 + Math.min(prevDiag, prev[j - 1], prev[j]);
|
|
64
|
+
}
|
|
65
|
+
prevDiag = temp;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return prev[n];
|
|
70
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Tokenizer
|
|
2
|
+
export {
|
|
3
|
+
tokenize,
|
|
4
|
+
isKeyValue,
|
|
5
|
+
parseKeyValue,
|
|
6
|
+
isArrow,
|
|
7
|
+
isSelector,
|
|
8
|
+
} from "./tokenizer.js";
|
|
9
|
+
|
|
10
|
+
// Parsed operation
|
|
11
|
+
export {
|
|
12
|
+
parseOp,
|
|
13
|
+
isParseError,
|
|
14
|
+
type ParsedOp,
|
|
15
|
+
type ParseError,
|
|
16
|
+
} from "./parsed-op.js";
|
|
17
|
+
|
|
18
|
+
// Event log
|
|
19
|
+
export { EventLog } from "./event-log.js";
|
|
20
|
+
|
|
21
|
+
// Verb registry
|
|
22
|
+
export { VerbRegistry, type VerbSpec } from "./verb-registry.js";
|
|
23
|
+
|
|
24
|
+
// Session
|
|
25
|
+
export {
|
|
26
|
+
SessionDispatcher,
|
|
27
|
+
type SessionHooks,
|
|
28
|
+
} from "./session.js";
|
|
29
|
+
|
|
30
|
+
// Formatter
|
|
31
|
+
export { formatResult, suggest } from "./formatter.js";
|
|
32
|
+
|
|
33
|
+
// Server
|
|
34
|
+
export {
|
|
35
|
+
createFcpServer,
|
|
36
|
+
type FcpServerConfig,
|
|
37
|
+
type FcpDomainAdapter,
|
|
38
|
+
type OpResult,
|
|
39
|
+
type QueryResult,
|
|
40
|
+
} from "./server.js";
|
package/src/parsed-op.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { tokenize, isKeyValue, parseKeyValue, isSelector, isArrow } from "./tokenizer.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A successfully parsed operation.
|
|
5
|
+
*/
|
|
6
|
+
export interface ParsedOp {
|
|
7
|
+
verb: string;
|
|
8
|
+
positionals: string[];
|
|
9
|
+
params: Record<string, string>;
|
|
10
|
+
selectors: string[];
|
|
11
|
+
raw: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A parse failure.
|
|
16
|
+
*/
|
|
17
|
+
export interface ParseError {
|
|
18
|
+
success: false;
|
|
19
|
+
error: string;
|
|
20
|
+
raw: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Parse an operation string into a structured ParsedOp.
|
|
25
|
+
*
|
|
26
|
+
* First token becomes the verb. Remaining tokens are classified:
|
|
27
|
+
* @-prefixed -> selectors
|
|
28
|
+
* key:value -> params
|
|
29
|
+
* everything else -> positionals (in order)
|
|
30
|
+
*/
|
|
31
|
+
export function parseOp(input: string): ParsedOp | ParseError {
|
|
32
|
+
const raw = input.trim();
|
|
33
|
+
const tokens = tokenize(raw);
|
|
34
|
+
|
|
35
|
+
if (tokens.length === 0) {
|
|
36
|
+
return { success: false, error: "empty operation", raw };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const verb = tokens[0].toLowerCase();
|
|
40
|
+
const positionals: string[] = [];
|
|
41
|
+
const params: Record<string, string> = {};
|
|
42
|
+
const selectors: string[] = [];
|
|
43
|
+
|
|
44
|
+
for (let i = 1; i < tokens.length; i++) {
|
|
45
|
+
const token = tokens[i];
|
|
46
|
+
if (isSelector(token)) {
|
|
47
|
+
selectors.push(token);
|
|
48
|
+
} else if (isKeyValue(token)) {
|
|
49
|
+
const { key, value } = parseKeyValue(token);
|
|
50
|
+
params[key] = value;
|
|
51
|
+
} else {
|
|
52
|
+
positionals.push(token);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return { verb, positionals, params, selectors, raw };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Type guard: check if a parse result is an error.
|
|
61
|
+
*/
|
|
62
|
+
export function isParseError(result: ParsedOp | ParseError): result is ParseError {
|
|
63
|
+
return "success" in result && result.success === false;
|
|
64
|
+
}
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { EventLog } from "./event-log.js";
|
|
4
|
+
import { parseOp, isParseError } from "./parsed-op.js";
|
|
5
|
+
import type { ParsedOp } from "./parsed-op.js";
|
|
6
|
+
import { VerbRegistry } from "./verb-registry.js";
|
|
7
|
+
import type { VerbSpec } from "./verb-registry.js";
|
|
8
|
+
import { SessionDispatcher, type SessionHooks } from "./session.js";
|
|
9
|
+
import { formatResult, suggest } from "./formatter.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Result of executing a single operation.
|
|
13
|
+
*/
|
|
14
|
+
export interface OpResult {
|
|
15
|
+
success: boolean;
|
|
16
|
+
message: string;
|
|
17
|
+
prefix?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Result of a query that may include an image.
|
|
22
|
+
*/
|
|
23
|
+
export interface QueryResult {
|
|
24
|
+
text: string;
|
|
25
|
+
image?: { base64: string; mimeType: string };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Domain adapter that an FCP domain must implement.
|
|
30
|
+
*/
|
|
31
|
+
export interface FcpDomainAdapter<Model, Event> {
|
|
32
|
+
/** Create a new empty model. */
|
|
33
|
+
createEmpty(title: string, params: Record<string, string>): Model;
|
|
34
|
+
/** Serialize model to saveable format. */
|
|
35
|
+
serialize(model: Model): Buffer | string;
|
|
36
|
+
/** Deserialize from file contents. */
|
|
37
|
+
deserialize(data: Buffer | string): Model;
|
|
38
|
+
/** Rebuild derived indices (e.g., after undo/redo). */
|
|
39
|
+
rebuildIndices(model: Model): void;
|
|
40
|
+
/** Return a compact digest for drift detection. */
|
|
41
|
+
getDigest(model: Model): string;
|
|
42
|
+
/** Execute a parsed operation against the model. */
|
|
43
|
+
dispatchOp(op: ParsedOp, model: Model, log: EventLog<Event>): OpResult | Promise<OpResult>;
|
|
44
|
+
/** Execute a query against the model. */
|
|
45
|
+
dispatchQuery(query: string, model: Model): string | QueryResult | Promise<string | QueryResult>;
|
|
46
|
+
/** Reverse a single event (for undo). */
|
|
47
|
+
reverseEvent(event: Event, model: Model): void;
|
|
48
|
+
/** Replay a single event (for redo). */
|
|
49
|
+
replayEvent(event: Event, model: Model): void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Configuration for creating an FCP MCP server.
|
|
54
|
+
*/
|
|
55
|
+
export interface FcpServerConfig<Model, Event> {
|
|
56
|
+
/** Domain name (e.g., "midi", "studio"). Used as tool name prefix. */
|
|
57
|
+
domain: string;
|
|
58
|
+
/** Domain adapter implementing all domain-specific logic. */
|
|
59
|
+
adapter: FcpDomainAdapter<Model, Event>;
|
|
60
|
+
/** Verb specifications for this domain. */
|
|
61
|
+
verbs: VerbSpec[];
|
|
62
|
+
/** Optional reference card configuration. */
|
|
63
|
+
referenceCard?: { sections?: Record<string, string> };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Create an MCP server wired up with FCP conventions.
|
|
68
|
+
*
|
|
69
|
+
* Registers 4 tools:
|
|
70
|
+
* {domain} — primary mutation tool (ops array)
|
|
71
|
+
* {domain}_query — read-only queries
|
|
72
|
+
* {domain}_session — lifecycle (new, open, save, checkpoint, undo, redo)
|
|
73
|
+
* {domain}_help — reference card
|
|
74
|
+
*/
|
|
75
|
+
export function createFcpServer<Model, Event>(
|
|
76
|
+
config: FcpServerConfig<Model, Event>,
|
|
77
|
+
): McpServer {
|
|
78
|
+
const { domain, adapter, verbs, referenceCard } = config;
|
|
79
|
+
|
|
80
|
+
// Build registry and reference card
|
|
81
|
+
const registry = new VerbRegistry();
|
|
82
|
+
registry.registerMany(verbs);
|
|
83
|
+
const refCard = registry.generateReferenceCard(referenceCard?.sections);
|
|
84
|
+
|
|
85
|
+
// Event log
|
|
86
|
+
const eventLog = new EventLog<Event>();
|
|
87
|
+
|
|
88
|
+
// Session dispatcher with hooks
|
|
89
|
+
const sessionHooks: SessionHooks<Model> = {
|
|
90
|
+
onNew(params) {
|
|
91
|
+
return adapter.createEmpty(params["title"] ?? "Untitled", params);
|
|
92
|
+
},
|
|
93
|
+
async onOpen(path) {
|
|
94
|
+
const { readFile } = await import("node:fs/promises");
|
|
95
|
+
const data = await readFile(path);
|
|
96
|
+
const model = adapter.deserialize(data);
|
|
97
|
+
adapter.rebuildIndices(model);
|
|
98
|
+
return model;
|
|
99
|
+
},
|
|
100
|
+
async onSave(model, path) {
|
|
101
|
+
const { writeFile } = await import("node:fs/promises");
|
|
102
|
+
const data = adapter.serialize(model);
|
|
103
|
+
await writeFile(path, data);
|
|
104
|
+
},
|
|
105
|
+
onRebuildIndices(model) {
|
|
106
|
+
adapter.rebuildIndices(model);
|
|
107
|
+
},
|
|
108
|
+
getDigest(model) {
|
|
109
|
+
return adapter.getDigest(model);
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
const session = new SessionDispatcher<Model, Event>(sessionHooks, eventLog, {
|
|
113
|
+
reverseEvent: (event, model) => adapter.reverseEvent(event, model),
|
|
114
|
+
replayEvent: (event, model) => adapter.replayEvent(event, model),
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// MCP server
|
|
118
|
+
const server = new McpServer({
|
|
119
|
+
name: `fcp-${domain}`,
|
|
120
|
+
version: "0.1.0",
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// ── Primary mutation tool ──────────────────────────────
|
|
124
|
+
server.tool(
|
|
125
|
+
domain,
|
|
126
|
+
`Execute ${domain} operations. Each op string follows the FCP verb DSL.\n\n${refCard}`,
|
|
127
|
+
{
|
|
128
|
+
ops: z
|
|
129
|
+
.array(z.string())
|
|
130
|
+
.describe("Array of operation strings"),
|
|
131
|
+
},
|
|
132
|
+
async ({ ops }) => {
|
|
133
|
+
const model = session.model;
|
|
134
|
+
if (!model) {
|
|
135
|
+
return {
|
|
136
|
+
content: [{ type: "text" as const, text: "error: no model loaded. Use session 'new' or 'open' first." }],
|
|
137
|
+
isError: true,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const lines: string[] = [];
|
|
142
|
+
let hasErrors = false;
|
|
143
|
+
|
|
144
|
+
for (const opStr of ops) {
|
|
145
|
+
const parsed = parseOp(opStr);
|
|
146
|
+
if (isParseError(parsed)) {
|
|
147
|
+
lines.push(`ERROR: ${parsed.error}`);
|
|
148
|
+
hasErrors = true;
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Check if verb is known
|
|
153
|
+
const spec = registry.lookup(parsed.verb);
|
|
154
|
+
if (!spec) {
|
|
155
|
+
const suggestion = suggest(parsed.verb, verbs.map((v) => v.verb));
|
|
156
|
+
const msg = `unknown verb "${parsed.verb}"`;
|
|
157
|
+
lines.push(suggestion ? `ERROR: ${msg}\n try: ${suggestion}` : `ERROR: ${msg}`);
|
|
158
|
+
hasErrors = true;
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const result = await adapter.dispatchOp(parsed, model, eventLog);
|
|
163
|
+
lines.push(formatResult(result.success, result.message, result.prefix));
|
|
164
|
+
if (!result.success) hasErrors = true;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Append digest
|
|
168
|
+
lines.push(adapter.getDigest(model));
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
content: [{ type: "text" as const, text: lines.join("\n") }],
|
|
172
|
+
isError: hasErrors,
|
|
173
|
+
};
|
|
174
|
+
},
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
// ── Query tool ─────────────────────────────────────────
|
|
178
|
+
server.tool(
|
|
179
|
+
`${domain}_query`,
|
|
180
|
+
`Query ${domain} state. Read-only.`,
|
|
181
|
+
{
|
|
182
|
+
q: z.string().describe("Query string"),
|
|
183
|
+
},
|
|
184
|
+
async ({ q }) => {
|
|
185
|
+
const model = session.model;
|
|
186
|
+
if (!model) {
|
|
187
|
+
return {
|
|
188
|
+
content: [{ type: "text" as const, text: "error: no model loaded." }],
|
|
189
|
+
isError: true,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const result = await adapter.dispatchQuery(q, model);
|
|
194
|
+
|
|
195
|
+
if (typeof result === "string") {
|
|
196
|
+
return { content: [{ type: "text" as const, text: result }] };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const qr = result as QueryResult;
|
|
200
|
+
const content: Array<
|
|
201
|
+
| { type: "text"; text: string }
|
|
202
|
+
| { type: "image"; data: string; mimeType: string }
|
|
203
|
+
> = [];
|
|
204
|
+
if (qr.image) {
|
|
205
|
+
content.push({ type: "image" as const, data: qr.image.base64, mimeType: qr.image.mimeType });
|
|
206
|
+
}
|
|
207
|
+
content.push({ type: "text" as const, text: qr.text });
|
|
208
|
+
return { content };
|
|
209
|
+
},
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
// ── Session tool ───────────────────────────────────────
|
|
213
|
+
server.tool(
|
|
214
|
+
`${domain}_session`,
|
|
215
|
+
`${domain} lifecycle: new, open, save, checkpoint, undo, redo.`,
|
|
216
|
+
{
|
|
217
|
+
action: z.string().describe(
|
|
218
|
+
"Action: 'new \"Title\"', 'open ./file', 'save', 'save as:./out', 'checkpoint v1', 'undo', 'undo to:v1', 'redo'",
|
|
219
|
+
),
|
|
220
|
+
},
|
|
221
|
+
async ({ action }) => {
|
|
222
|
+
const text = await session.dispatch(action);
|
|
223
|
+
const model = session.model;
|
|
224
|
+
const digest = model ? adapter.getDigest(model) : "";
|
|
225
|
+
const output = digest ? `${text}\n${digest}` : text;
|
|
226
|
+
return { content: [{ type: "text" as const, text: output }] };
|
|
227
|
+
},
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
// ── Help tool ──────────────────────────────────────────
|
|
231
|
+
server.tool(
|
|
232
|
+
`${domain}_help`,
|
|
233
|
+
`Returns the ${domain} FCP reference card.`,
|
|
234
|
+
{},
|
|
235
|
+
async () => {
|
|
236
|
+
return { content: [{ type: "text" as const, text: refCard }] };
|
|
237
|
+
},
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
return server;
|
|
241
|
+
}
|