@ng-annotate/mcp-server 0.2.1

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.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,14 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
+ import { registerTools } from './tools.js';
4
+ async function main() {
5
+ const server = new McpServer({
6
+ name: 'ng-annotate',
7
+ version: '0.1.0',
8
+ description: 'Connects an AI agent to a live Angular dev session for annotation-driven code changes',
9
+ });
10
+ registerTools(server);
11
+ const transport = new StdioServerTransport();
12
+ await server.connect(transport);
13
+ }
14
+ main().catch(console.error);
@@ -0,0 +1,2 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export declare function registerTools(server: McpServer): void;
@@ -0,0 +1,190 @@
1
+ import { z } from 'zod';
2
+ import { store } from '../../vite-plugin/src/store.js';
3
+ // ─── Response helpers ─────────────────────────────────────────────────────────
4
+ function json(data) {
5
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
6
+ }
7
+ function error(message) {
8
+ return {
9
+ content: [{ type: 'text', text: `Error: ${message}` }],
10
+ isError: true,
11
+ };
12
+ }
13
+ // ─── Constants ────────────────────────────────────────────────────────────────
14
+ const DEFAULT_WATCH_TIMEOUT_MS = 25000;
15
+ const WATCH_POLL_INTERVAL_MS = 500;
16
+ // ─── Work loop prompt ─────────────────────────────────────────────────────────
17
+ const WORK_LOOP_PROMPT = `\
18
+ Start the ng-annotate annotation work loop. Follow these steps continuously until told to stop.
19
+
20
+ ## Startup
21
+ 1. Call \`get_all_pending\` to drain any annotations that arrived before you connected.
22
+ 2. Process each pending annotation (see Processing below).
23
+
24
+ ## Watch loop
25
+ 3. Call \`watch_annotations\` (default timeout).
26
+ 4. If it returns \`{"status":"annotations",...}\`: process each annotation in the list.
27
+ 5. If it returns \`{"status":"timeout"}\`: call \`watch_annotations\` again immediately.
28
+ 6. After processing, return to step 3.
29
+
30
+ ## Processing an annotation
31
+ 1. Call \`acknowledge\` with the annotation ID — immediately, before reading any files.
32
+ 2. Read \`componentFilePath\` and (if present) \`templateFilePath\` from the annotation data.
33
+ 3. Understand the intent: \`annotationText\` is the instruction; \`selectionText\` is the highlighted target (prefer this as the primary focus when present).
34
+ 4. Edit the component template, TypeScript, or SCSS as needed. Make minimal, targeted changes — do not refactor surrounding code.
35
+ 5. If the template change also requires a TypeScript change (e.g. adding a property), make both.
36
+ 6. Once the change is written to disk: call \`resolve\` with a one-sentence summary.
37
+ 7. If you need clarification: call \`reply\` with a question. Do NOT resolve yet.
38
+ 8. If the request is out of scope or not actionable: call \`dismiss\` with a reason.
39
+
40
+ ## Rules
41
+ - Always \`acknowledge\` before touching any files.
42
+ - Never modify files without a corresponding annotation.
43
+ - Never \`resolve\` until the change is actually written to disk.
44
+ `;
45
+ // ─── Tool and prompt registration ─────────────────────────────────────────────
46
+ export function registerTools(server) {
47
+ // ── Prompt ────────────────────────────────────────────────────────────────
48
+ server.registerPrompt('start-polling', {
49
+ description: 'Start the ng-annotate annotation watch loop. Injects the full work loop instructions into the conversation.',
50
+ }, () => ({
51
+ messages: [{ role: 'user', content: { type: 'text', text: WORK_LOOP_PROMPT } }],
52
+ }));
53
+ // ── Session tools ─────────────────────────────────────────────────────────
54
+ server.registerTool('list_sessions', { description: 'List all browser sessions connected to the dev server' }, async () => {
55
+ const sessions = await store.listSessions();
56
+ return json(sessions);
57
+ });
58
+ server.registerTool('get_session', {
59
+ description: 'Get a session and all its annotations',
60
+ inputSchema: { id: z.string().describe('Session ID') },
61
+ }, async ({ id }) => {
62
+ const session = await store.getSession(id);
63
+ if (!session)
64
+ return error(`Session not found: ${id}`);
65
+ const annotations = await store.listAnnotations(id);
66
+ return json({ session, annotations });
67
+ });
68
+ // ── Query tools ───────────────────────────────────────────────────────────
69
+ server.registerTool('get_pending', {
70
+ description: 'Get all pending annotations for a specific session',
71
+ inputSchema: { sessionId: z.string().describe('Session ID') },
72
+ }, async ({ sessionId }) => {
73
+ const annotations = await store.listAnnotations(sessionId, 'pending');
74
+ return json(annotations);
75
+ });
76
+ server.registerTool('get_all_pending', {
77
+ description: 'Startup step: get all pending annotations across all sessions, sorted oldest first. Call this on startup to drain annotations that arrived before the agent connected, then enter the watch_annotations loop.',
78
+ }, async () => {
79
+ const annotations = await store.listAnnotations(undefined, 'pending');
80
+ return json(annotations);
81
+ });
82
+ // ── Action tools ──────────────────────────────────────────────────────────
83
+ server.registerTool('acknowledge', {
84
+ description: "Acknowledge a pending annotation (pending → acknowledged). Call this IMMEDIATELY after receiving an annotation and BEFORE reading any files or starting edits. This signals to the browser that the agent is working on it.",
85
+ inputSchema: {
86
+ id: z.string().describe('Annotation ID'),
87
+ message: z.string().optional().describe('Optional message to add as a reply'),
88
+ },
89
+ }, async ({ id, message }) => {
90
+ const annotation = await store.getAnnotation(id);
91
+ if (!annotation)
92
+ return error(`Annotation not found: ${id}`);
93
+ if (annotation.status === 'acknowledged')
94
+ return error(`Annotation ${id} is already acknowledged`);
95
+ const updated = await store.updateAnnotation(id, { status: 'acknowledged' });
96
+ if (!updated)
97
+ return error(`Failed to update annotation: ${id}`);
98
+ if (message) {
99
+ await store.addReply(id, { author: 'agent', message });
100
+ }
101
+ return json(updated);
102
+ });
103
+ server.registerTool('resolve', {
104
+ description: 'Mark an annotation as resolved. Call this ONLY after the file change has been successfully written to disk. Include a one-sentence summary of what was changed.',
105
+ inputSchema: {
106
+ id: z.string().describe('Annotation ID'),
107
+ summary: z.string().optional().describe('One-sentence summary of what was changed'),
108
+ },
109
+ }, async ({ id, summary }) => {
110
+ const annotation = await store.getAnnotation(id);
111
+ if (!annotation)
112
+ return error(`Annotation not found: ${id}`);
113
+ const updated = await store.updateAnnotation(id, { status: 'resolved' });
114
+ if (!updated)
115
+ return error(`Failed to update annotation: ${id}`);
116
+ if (summary) {
117
+ await store.addReply(id, { author: 'agent', message: summary });
118
+ }
119
+ return json(updated);
120
+ });
121
+ server.registerTool('dismiss', {
122
+ description: 'Dismiss an annotation as not actionable. Use when the request is out of scope, contradicts existing code, or cannot be safely implemented. A reason is required.',
123
+ inputSchema: {
124
+ id: z.string().describe('Annotation ID'),
125
+ reason: z.string().describe('Reason for dismissal (required)'),
126
+ },
127
+ }, async ({ id, reason }) => {
128
+ if (!reason)
129
+ return error('Reason is required for dismissal');
130
+ const annotation = await store.getAnnotation(id);
131
+ if (!annotation)
132
+ return error(`Annotation not found: ${id}`);
133
+ const updated = await store.updateAnnotation(id, { status: 'dismissed' });
134
+ if (!updated)
135
+ return error(`Failed to update annotation: ${id}`);
136
+ await store.addReply(id, { author: 'agent', message: reason });
137
+ return json(updated);
138
+ });
139
+ server.registerTool('reply', {
140
+ description: 'Add an agent reply to an annotation thread without resolving it. Use to ask for clarification when the request is ambiguous. Do NOT call resolve until the user has responded.',
141
+ inputSchema: {
142
+ id: z.string().describe('Annotation ID'),
143
+ message: z.string().describe('Reply message'),
144
+ },
145
+ }, async ({ id, message }) => {
146
+ const annotation = await store.getAnnotation(id);
147
+ if (!annotation)
148
+ return error(`Annotation not found: ${id}`);
149
+ const updated = await store.addReply(id, { author: 'agent', message });
150
+ if (!updated)
151
+ return error(`Failed to add reply to annotation: ${id}`);
152
+ return json(updated);
153
+ });
154
+ // ── Watch tool ────────────────────────────────────────────────────────────
155
+ server.registerTool('watch_annotations', {
156
+ description: `Long-poll for new pending annotations (polls every 500ms, default ${String(DEFAULT_WATCH_TIMEOUT_MS / 1000)}s timeout). Returns immediately if pending annotations already exist. This is the main event loop — call it again after processing annotations or on timeout, and keep calling it indefinitely. Result shape: {"status":"annotations","annotations":[...]} or {"status":"timeout"}.`,
157
+ inputSchema: {
158
+ sessionId: z.string().optional().describe('Optional session ID to filter by'),
159
+ timeoutMs: z
160
+ .number()
161
+ .optional()
162
+ .describe(`Timeout in milliseconds (default: ${String(DEFAULT_WATCH_TIMEOUT_MS)})`),
163
+ },
164
+ }, async ({ sessionId, timeoutMs }) => {
165
+ const timeout = timeoutMs ?? DEFAULT_WATCH_TIMEOUT_MS;
166
+ // Check immediately first
167
+ const existing = await store.listAnnotations(sessionId, 'pending');
168
+ if (existing.length > 0) {
169
+ return json({ status: 'annotations', annotations: existing });
170
+ }
171
+ // Poll for the duration of timeout
172
+ const result = await new Promise((resolve) => {
173
+ const interval = setInterval(() => {
174
+ void (async () => {
175
+ const pending = await store.listAnnotations(sessionId, 'pending');
176
+ if (pending.length > 0) {
177
+ clearInterval(interval);
178
+ clearTimeout(timer);
179
+ resolve({ status: 'annotations', annotations: pending });
180
+ }
181
+ })();
182
+ }, WATCH_POLL_INTERVAL_MS);
183
+ const timer = setTimeout(() => {
184
+ clearInterval(interval);
185
+ resolve({ status: 'timeout' });
186
+ }, timeout);
187
+ });
188
+ return json(result);
189
+ });
190
+ }
@@ -0,0 +1,47 @@
1
+ export type AnnotationStatus = 'pending' | 'acknowledged' | 'resolved' | 'dismissed';
2
+ export interface Session {
3
+ id: string;
4
+ createdAt: string;
5
+ lastSeenAt: string;
6
+ active: boolean;
7
+ url: string;
8
+ }
9
+ export interface AnnotationReply {
10
+ id: string;
11
+ createdAt: string;
12
+ author: 'agent' | 'user';
13
+ message: string;
14
+ }
15
+ export interface Annotation {
16
+ id: string;
17
+ sessionId: string;
18
+ createdAt: string;
19
+ status: AnnotationStatus;
20
+ replies: AnnotationReply[];
21
+ componentName: string;
22
+ componentFilePath: string;
23
+ templateFilePath?: string;
24
+ selector: string;
25
+ inputs: Record<string, unknown>;
26
+ domSnapshot: string;
27
+ componentTreePath: string[];
28
+ annotationText: string;
29
+ selectionText?: string;
30
+ }
31
+ export declare const STORE_DIR = ".ng-annotate";
32
+ export declare const STORE_PATH: string;
33
+ export declare function ensureStore(): void;
34
+ type WatcherFn = (annotation: Annotation) => void;
35
+ export declare function addWatcher(fn: WatcherFn): () => void;
36
+ export declare const store: {
37
+ createSession(payload: Omit<Session, "id" | "createdAt" | "lastSeenAt">): Promise<Session>;
38
+ updateSession(id: string, patch: Partial<Session>): Promise<Session | undefined>;
39
+ listSessions(): Promise<Session[]>;
40
+ getSession(id: string): Promise<Session | undefined>;
41
+ createAnnotation(payload: Omit<Annotation, "id" | "createdAt" | "status" | "replies">): Promise<Annotation>;
42
+ getAnnotation(id: string): Promise<Annotation | undefined>;
43
+ listAnnotations(sessionId?: string, status?: AnnotationStatus): Promise<Annotation[]>;
44
+ updateAnnotation(id: string, patch: Partial<Annotation>): Promise<Annotation | undefined>;
45
+ addReply(annotationId: string, reply: Omit<AnnotationReply, "id" | "createdAt">): Promise<Annotation | undefined>;
46
+ };
47
+ export {};
@@ -0,0 +1,146 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { v4 as uuidv4 } from 'uuid';
4
+ const EMPTY_STORE = { sessions: {}, annotations: {} };
5
+ // ─── File paths ───────────────────────────────────────────────────────────────
6
+ export const STORE_DIR = '.ng-annotate';
7
+ export const STORE_PATH = path.join(process.cwd(), STORE_DIR, 'store.json');
8
+ // ─── Store init ───────────────────────────────────────────────────────────────
9
+ export function ensureStore() {
10
+ const dir = path.join(process.cwd(), STORE_DIR);
11
+ if (!fs.existsSync(dir))
12
+ fs.mkdirSync(dir, { recursive: true });
13
+ if (!fs.existsSync(STORE_PATH)) {
14
+ fs.writeFileSync(STORE_PATH, JSON.stringify(EMPTY_STORE, null, 2), 'utf8');
15
+ }
16
+ }
17
+ // ─── File locking ─────────────────────────────────────────────────────────────
18
+ // In-process mutex: serializes all writes from the same process using a promise chain.
19
+ // writeQueue always resolves (never rejects) so the chain is never broken by a
20
+ // failing task. The Promise returned to the caller still rejects on error.
21
+ // Note: cross-process safety (Vite plugin + MCP server) can be layered on top
22
+ // via proper-lockfile if needed, but requires a platform-reliable approach.
23
+ let writeQueue = Promise.resolve();
24
+ async function withLock(fn) {
25
+ ensureStore();
26
+ const result = writeQueue.then(async () => {
27
+ const raw = fs.readFileSync(STORE_PATH, 'utf8');
28
+ const data = JSON.parse(raw);
29
+ const updated = await fn(data);
30
+ fs.writeFileSync(STORE_PATH, JSON.stringify(updated, null, 2), 'utf8');
31
+ return updated;
32
+ });
33
+ // Keep the queue alive even when this task fails
34
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
35
+ writeQueue = result.catch(() => { });
36
+ return result;
37
+ }
38
+ function readStore() {
39
+ ensureStore();
40
+ return JSON.parse(fs.readFileSync(STORE_PATH, 'utf8'));
41
+ }
42
+ const watchers = new Set();
43
+ export function addWatcher(fn) {
44
+ watchers.add(fn);
45
+ return () => watchers.delete(fn);
46
+ }
47
+ function notifyWatchers(annotation) {
48
+ for (const fn of watchers)
49
+ fn(annotation);
50
+ }
51
+ // ─── Store API ────────────────────────────────────────────────────────────────
52
+ export const store = {
53
+ async createSession(payload) {
54
+ const now = new Date().toISOString();
55
+ const session = {
56
+ id: uuidv4(),
57
+ createdAt: now,
58
+ lastSeenAt: now,
59
+ ...payload,
60
+ };
61
+ await withLock((data) => {
62
+ data.sessions[session.id] = session;
63
+ return data;
64
+ });
65
+ return session;
66
+ },
67
+ async updateSession(id, patch) {
68
+ let result;
69
+ await withLock((data) => {
70
+ const existing = data.sessions[id];
71
+ if (!existing)
72
+ return data;
73
+ const updated = { ...existing, ...patch, id };
74
+ data.sessions[id] = updated;
75
+ result = updated;
76
+ return data;
77
+ });
78
+ return result;
79
+ },
80
+ listSessions() {
81
+ const data = readStore();
82
+ return Promise.resolve(Object.values(data.sessions).filter((s) => s !== undefined));
83
+ },
84
+ getSession(id) {
85
+ const data = readStore();
86
+ return Promise.resolve(data.sessions[id]);
87
+ },
88
+ async createAnnotation(payload) {
89
+ const annotation = {
90
+ id: uuidv4(),
91
+ createdAt: new Date().toISOString(),
92
+ status: 'pending',
93
+ replies: [],
94
+ ...payload,
95
+ };
96
+ await withLock((data) => {
97
+ data.annotations[annotation.id] = annotation;
98
+ return data;
99
+ });
100
+ notifyWatchers(annotation);
101
+ return annotation;
102
+ },
103
+ getAnnotation(id) {
104
+ const data = readStore();
105
+ return Promise.resolve(data.annotations[id]);
106
+ },
107
+ listAnnotations(sessionId, status) {
108
+ const data = readStore();
109
+ let list = Object.values(data.annotations).filter((a) => a !== undefined);
110
+ if (sessionId)
111
+ list = list.filter((a) => a.sessionId === sessionId);
112
+ if (status)
113
+ list = list.filter((a) => a.status === status);
114
+ return Promise.resolve(list.sort((a, b) => a.createdAt.localeCompare(b.createdAt)));
115
+ },
116
+ async updateAnnotation(id, patch) {
117
+ let result;
118
+ await withLock((data) => {
119
+ const existing = data.annotations[id];
120
+ if (!existing)
121
+ return data;
122
+ const updated = { ...existing, ...patch, id };
123
+ data.annotations[id] = updated;
124
+ result = updated;
125
+ return data;
126
+ });
127
+ return result;
128
+ },
129
+ async addReply(annotationId, reply) {
130
+ let result;
131
+ await withLock((data) => {
132
+ const annotation = data.annotations[annotationId];
133
+ if (!annotation)
134
+ return data;
135
+ const newReply = {
136
+ id: uuidv4(),
137
+ createdAt: new Date().toISOString(),
138
+ ...reply,
139
+ };
140
+ annotation.replies.push(newReply);
141
+ result = annotation;
142
+ return data;
143
+ });
144
+ return result;
145
+ },
146
+ };
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@ng-annotate/mcp-server",
3
+ "version": "0.2.1",
4
+ "type": "module",
5
+ "bin": {
6
+ "ng-annotate-mcp": "dist/index.js"
7
+ },
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/yngvebn/ngagentify"
11
+ },
12
+ "files": [
13
+ "dist"
14
+ ],
15
+ "main": "dist/index.js",
16
+ "types": "dist/index.d.ts",
17
+ "scripts": {
18
+ "build": "tsc",
19
+ "build:watch": "tsc --watch",
20
+ "test": "vitest run",
21
+ "test:watch": "vitest",
22
+ "test:coverage": "vitest run --coverage",
23
+ "lint": "eslint src/",
24
+ "lint:fix": "eslint src/ --fix"
25
+ },
26
+ "devDependencies": {
27
+ "@types/node": "^25.3.2",
28
+ "@vitest/coverage-v8": "^4.0.18",
29
+ "typescript": "~5.6.0",
30
+ "vitest": "^4.0.18"
31
+ },
32
+ "dependencies": {
33
+ "@modelcontextprotocol/sdk": "^1.26.0",
34
+ "zod": "^4.3.6"
35
+ }
36
+ }