sygnal 6.0.0 → 6.1.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.
- package/CHANGELOG.md +105 -0
- package/dist/ai.cjs.js +115 -0
- package/dist/ai.cjs.js.map +1 -0
- package/dist/ai.esm.js +2 -0
- package/dist/ai.esm.js.map +1 -0
- package/dist/astro/index.cjs.js +659 -18
- package/dist/astro/index.cjs.js.map +1 -1
- package/dist/astro/index.mjs +659 -18
- package/dist/astro/index.mjs.map +1 -1
- package/dist/astro/server.cjs.js.map +1 -1
- package/dist/astro/server.mjs.map +1 -1
- package/dist/devtools.cjs.js +413 -4
- package/dist/devtools.cjs.js.map +1 -1
- package/dist/devtools.esm.js +413 -5
- package/dist/devtools.esm.js.map +1 -1
- package/dist/diagnostics.cjs.js +170 -12
- package/dist/diagnostics.cjs.js.map +1 -1
- package/dist/diagnostics.esm.js +170 -12
- package/dist/diagnostics.esm.js.map +1 -1
- package/dist/guide/accessibility.md +54 -1
- package/dist/guide/agent.md +596 -0
- package/dist/guide/ai-chat.md +693 -0
- package/dist/guide/ai-decisions.md +330 -0
- package/dist/guide/forms-reference.md +28 -0
- package/dist/guide/forms.md +2 -1
- package/dist/guide/http.md +2 -0
- package/dist/guide/mcp-apps.md +272 -0
- package/dist/guide/recipes/ai-form-fill.md +163 -0
- package/dist/guide/recipes/ai-summarize.md +155 -0
- package/dist/guide/recipes/ai-support-inbox.md +190 -0
- package/dist/guide/recipes/overview.md +10 -0
- package/dist/guide/webmcp.md +226 -0
- package/dist/index.cjs.js +7841 -3370
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +1332 -6
- package/dist/index.esm.js +7139 -2696
- package/dist/index.esm.js.map +1 -1
- package/dist/sygnal.min.js +1 -1
- package/dist/sygnal.min.js.map +1 -1
- package/dist/vike/config/package.json +1 -1
- package/dist/vite/plugin.cjs.js +659 -18
- package/dist/vite/plugin.cjs.js.map +1 -1
- package/dist/vite/plugin.mjs +659 -18
- package/dist/vite/plugin.mjs.map +1 -1
- package/llms.txt +7 -1
- package/package.json +15 -1
- package/src/ai.d.ts +1186 -0
- package/src/ai.ts +23 -0
- package/src/core/hooks.ts +1 -1
- package/src/devtools.d.ts +20 -1
- package/src/devtools.ts +3 -0
- package/src/extra/ai/agent/index.ts +484 -0
- package/src/extra/ai/answers.ts +115 -0
- package/src/extra/ai/chat/behavior.ts +336 -0
- package/src/extra/ai/chat/driver.ts +271 -0
- package/src/extra/ai/chat/memoryTransport.ts +61 -0
- package/src/extra/ai/chat/output.ts +37 -0
- package/src/extra/ai/commandBar.ts +281 -0
- package/src/extra/ai/decide.ts +104 -0
- package/src/extra/ai/index.ts +42 -0
- package/src/extra/ai/link.ts +90 -0
- package/src/extra/ai/mcpApp.ts +314 -0
- package/src/extra/ai/messages.ts +51 -0
- package/src/extra/ai/schema/index.ts +166 -0
- package/src/extra/ai/schema/jsonSchema.ts +72 -0
- package/src/extra/ai/schema/strict.ts +152 -0
- package/src/extra/ai/transports/agui.ts +177 -0
- package/src/extra/ai/transports/anthropicMessages.ts +175 -0
- package/src/extra/ai/transports/chatCompletions.ts +105 -0
- package/src/extra/ai/transports/chromePrompt.ts +77 -0
- package/src/extra/ai/transports/encodeOpenResponses.ts +87 -0
- package/src/extra/ai/transports/fromAISDK.ts +126 -0
- package/src/extra/ai/transports/openResponses.ts +105 -0
- package/src/extra/ai/transports/shared.ts +125 -0
- package/src/extra/ai/transports/tools.ts +59 -0
- package/src/extra/ai/transports/uiMessageStream.ts +107 -0
- package/src/extra/ai/webmcp.ts +260 -0
- package/src/extra/copyAsTest.ts +2 -2
- package/src/extra/devMcp.ts +386 -0
- package/src/extra/devtoolsActions.ts +1 -1
- package/src/extra/diagnostics/checks/actionLog.ts +3 -3
- package/src/extra/diagnostics/checks/chat.ts +71 -0
- package/src/extra/diagnostics/checks/forms.ts +8 -0
- package/src/extra/diagnostics/checks/index.ts +7 -1
- package/src/extra/diagnostics/checks/public.d.ts +63 -2
- package/src/extra/diagnostics/checks/wiring.ts +5 -2
- package/src/extra/diagnostics/codes.ts +67 -0
- package/src/extra/form.ts +22 -6
- package/src/extra/formTool.ts +150 -0
- package/src/extra/testing.ts +125 -8
- package/src/index.d.ts +119 -3
- package/src/index.ts +21 -1
- package/src/shared.ts +9 -0
- package/src/vite/mcp.ts +568 -0
- package/src/vite/plugin.d.ts +43 -5
- package/src/vite/plugin.ts +109 -22
package/dist/astro/index.cjs.js
CHANGED
|
@@ -58,6 +58,571 @@ function globalThisAlias(userAlias) {
|
|
|
58
58
|
return shim ? [{ find: /^globalthis$/, replacement: shim }] : [];
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
/**
|
|
62
|
+
* PLAN-6 E-1: the dev MCP endpoint of sygnal/vite (`sygnal({ mcp: true })`), dev server only.
|
|
63
|
+
*
|
|
64
|
+
* `/__sygnal/mcp` speaks MCP's streamable HTTP transport: JSON-RPC 2.0 over POST, answered as
|
|
65
|
+
* one `application/json` body, in both protocol eras (G-655: a dual-era server):
|
|
66
|
+
*
|
|
67
|
+
* - legacy, the `initialize` revisions (2024-11-05 … 2025-11-25). G-638: JSON replies make a
|
|
68
|
+
* complete server under the spec (the client must accept JSON or SSE; a server without a GET
|
|
69
|
+
* stream answers 405; session ids are optional, "MAY assign"). Methods: initialize, ping,
|
|
70
|
+
* tools/list, tools/call; notifications get 202; batches (2025-03-26) are answered.
|
|
71
|
+
* - modern, the stateless 2026-07-28 revision (no initialize, no sessions, no ping): each request
|
|
72
|
+
* carries `_meta['io.modelcontextprotocol/protocolVersion']` and `.../clientCapabilities`, and
|
|
73
|
+
* the `MCP-Protocol-Version`, `Mcp-Method` and (tools/call) `Mcp-Name` headers, which must
|
|
74
|
+
* match the body (else 400 with -32020 HeaderMismatch; missing `_meta` fields: 400, -32602).
|
|
75
|
+
* Methods: server/discover, tools/list, tools/call; any other gets 404 with -32601. Results
|
|
76
|
+
* carry `resultType: 'complete'` and `_meta['io.modelcontextprotocol/serverInfo']`; tools/list
|
|
77
|
+
* and server/discover are cacheable results (`ttlMs`, `cacheScope`). No subscriptions/listen
|
|
78
|
+
* (the tool list never changes), no MRTR input requests, no logging.
|
|
79
|
+
*
|
|
80
|
+
* The era comes from each request: a 2026-07-28 header or `_meta` version is modern, anything else
|
|
81
|
+
* legacy (no header: 2025-03-26, as the spec allows). An unknown version gets 400 with -32022
|
|
82
|
+
* (UnsupportedProtocolVersion, `data: { supported, requested }`), so a modern client retries
|
|
83
|
+
* with one this server speaks. The server never sends requests or notifications of its own (no
|
|
84
|
+
* sampling, no progress, tools/list never changes), so an SSE stream would carry nothing; without
|
|
85
|
+
* session ids each POST stands alone (the page state lives in the browser, not in a session).
|
|
86
|
+
* GET and DELETE get 405. Spec: modelcontextprotocol.io/specification/2026-07-28/basic/versioning
|
|
87
|
+
* and .../basic/transports/streamable-http. Hand-written like sygnal-check's stdio server (no
|
|
88
|
+
* dependency, D209).
|
|
89
|
+
*
|
|
90
|
+
* Tools that read the page (get_state, dispatch, component_tree, recent_actions,
|
|
91
|
+
* get_diagnostics, copy_as_test, agent_tools) go to an open tab through a PageBridge: in the dev
|
|
92
|
+
* server, Vite's HMR channel (custom events 'sygnal:mcp:*', see src/extra/devMcp.ts), request /
|
|
93
|
+
* response with ids and a timeout. With several tabs the most recently loaded or focused one
|
|
94
|
+
* answers, or the `tab` argument picks one; the result says which (`tab`), and `tabs` lists
|
|
95
|
+
* them with their apps. G-638: every run() app of a page is served: `app` (an index, or the
|
|
96
|
+
* root component's name) picks one on get_state, dispatch, component_tree, copy_as_test and
|
|
97
|
+
* agent_tools (default: the first, or the app that has `component`); `apps` lists them. check / graph / explain run sygnal-check's own MCP server in-process when sygnal-check
|
|
98
|
+
* is installed in the project.
|
|
99
|
+
*
|
|
100
|
+
* Security (the endpoint can read and change the running app): requests must come from the
|
|
101
|
+
* loopback interface; the Host header must be localhost, a *.localhost name, a loopback IP or a
|
|
102
|
+
* name in Vite's `server.allowedHosts`; a request with an Origin header must come from a
|
|
103
|
+
* localhost / loopback origin (a web page elsewhere, or a DNS-rebinding page, gets 403).
|
|
104
|
+
*/
|
|
105
|
+
// @ts-ignore
|
|
106
|
+
const MCP_PATH = '/__sygnal/mcp';
|
|
107
|
+
const MCP_HELLO = 'sygnal:mcp:hello';
|
|
108
|
+
const MCP_REQUEST = 'sygnal:mcp:request';
|
|
109
|
+
const MCP_RESPONSE = 'sygnal:mcp:response';
|
|
110
|
+
/** G-655: the stateless revisions (per-request `_meta`, no initialize), newest first */
|
|
111
|
+
const MODERN_VERSIONS = ['2026-07-28'];
|
|
112
|
+
/** the `initialize` revisions, newest first; initialize answers with the client's version when it knows it */
|
|
113
|
+
const LEGACY_VERSIONS = ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05'];
|
|
114
|
+
const SUPPORTED_VERSIONS = [...MODERN_VERSIONS, ...LEGACY_VERSIONS];
|
|
115
|
+
const META = 'io.modelcontextprotocol/';
|
|
116
|
+
const INSTRUCTIONS = 'The running Sygnal apps in the dev server\'s open page: component_tree to orient, get_state / recent_actions / get_diagnostics to read, dispatch or agent_tools to act, copy_as_test to turn a session into a test; apps lists the page\'s run() apps when there are several (`app` picks one). check, graph and explain (when present) are sygnal-check on the sources.';
|
|
117
|
+
/** 2026-07-28 cacheable results (tools/list, server/discover): the list is fixed while the dev server runs, not across restarts */
|
|
118
|
+
const CACHE = { ttlMs: 0, cacheScope: 'private' };
|
|
119
|
+
const MAX_BODY = 1 << 20;
|
|
120
|
+
class ToolError extends Error {
|
|
121
|
+
}
|
|
122
|
+
const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
123
|
+
const tabSchema = { type: 'integer', description: 'The tab to ask (see tabs); default: the most recently loaded or focused tab' };
|
|
124
|
+
const componentSchema = { type: ['string', 'integer'], description: 'A component name, or an instance id (see component_tree); default: the root' };
|
|
125
|
+
const appSchema = { type: ['string', 'integer'], description: "The app on the page: an index or the root component's name (see apps); default: the first app, or the one that has `component`" };
|
|
126
|
+
const obj = (properties, required) => ({
|
|
127
|
+
type: 'object', properties: { ...properties, tab: tabSchema }, ...(required ? { required } : {}), additionalProperties: false,
|
|
128
|
+
});
|
|
129
|
+
/** The tools answered by the page */
|
|
130
|
+
const PAGE_TOOLS = [
|
|
131
|
+
{
|
|
132
|
+
name: 'get_state',
|
|
133
|
+
title: 'Get component state',
|
|
134
|
+
description: "The live state of a component instance in the open page (the root by default). With several instances of a component (Collection items) you get each one's id and state. `path` reads one field: 'todos.0.text' or ['todos', 0, 'text'].",
|
|
135
|
+
inputSchema: obj({ component: componentSchema, app: appSchema, path: { type: ['string', 'array'], items: { type: ['string', 'integer'] }, description: "A field path: 'a.b.0' or ['a', 'b', 0]" } }),
|
|
136
|
+
annotations: { readOnlyHint: true },
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
name: 'dispatch',
|
|
140
|
+
title: 'Dispatch an action',
|
|
141
|
+
description: "Send an action to a component instance in the open page, as if its intent had produced it (recorded with cause 'agent'), and wait for the render. Returns the instance's new state. The action must be one of the component's model entries (an unknown name lists them).",
|
|
142
|
+
inputSchema: obj({ component: componentSchema, app: appSchema, action: { type: 'string', description: "The action name, e.g. 'ADD'" }, data: { description: 'The action data (any JSON value)' } }, ['action']),
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
name: 'component_tree',
|
|
146
|
+
title: 'Component tree (inspect)',
|
|
147
|
+
description: "The running apps' graph from inspect(): component instances (id, parentId, kind), their actions and what triggers them, state keys, context, EVENTS, children, intent selectors (matched or not) and the runtime diagnostics attached to each. `app` keeps one app's instances, `component` only that component's.",
|
|
148
|
+
inputSchema: obj({ component: componentSchema, app: { ...appSchema, description: "Only this app's instances: an index or the root component's name (see apps); default: every app" } }),
|
|
149
|
+
annotations: { readOnlyHint: true },
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
name: 'recent_actions',
|
|
153
|
+
title: 'Recent actions',
|
|
154
|
+
description: "The DevTools action log of the open page, newest last: type, data, component, instance, cause ('intent', 'agent', 'reply', ...), the sinks it reached and the state before / after a STATE change.",
|
|
155
|
+
inputSchema: obj({
|
|
156
|
+
limit: { type: 'integer', minimum: 1, maximum: 500, description: 'How many (default 20)' },
|
|
157
|
+
component: { type: 'string', description: 'Only this component' },
|
|
158
|
+
type: { type: 'string', description: 'Only this action type' },
|
|
159
|
+
cause: { type: 'string', enum: ['intent', 'next', 'reply', 'built-in', 'simulateAction', 'behavior', 'agent', 'setState'], description: 'Only this cause' },
|
|
160
|
+
}),
|
|
161
|
+
annotations: { readOnlyHint: true },
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
name: 'get_diagnostics',
|
|
165
|
+
title: 'Runtime diagnostics',
|
|
166
|
+
description: 'The runtime diagnostics the open page reported (SYG codes): code, severity, component, message, fix and docsUrl, with a summary. Use explain for a code you do not know.',
|
|
167
|
+
inputSchema: obj({
|
|
168
|
+
code: { type: 'string', description: "Only this code, e.g. 'SYG104'" },
|
|
169
|
+
severity: { type: 'string', enum: ['error', 'warn', 'info'] },
|
|
170
|
+
limit: { type: 'integer', minimum: 1, maximum: 500, description: 'How many, newest (default 100)' },
|
|
171
|
+
}),
|
|
172
|
+
annotations: { readOnlyHint: true },
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
name: 'copy_as_test',
|
|
176
|
+
title: 'Copy session as test',
|
|
177
|
+
description: "DevTools' Copy as test: a Vitest + renderComponent test file that replays what happened to a component instance in the open page (default: the root) and asserts its final state. Returns { code, complete, warnings, replayed }.",
|
|
178
|
+
inputSchema: obj({ component: componentSchema, app: appSchema, componentImport: { type: 'string', description: "Where the test imports the component from: a module path ('./App.jsx') or a whole import line" } }),
|
|
179
|
+
annotations: { readOnlyHint: true },
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
name: 'agent_tools',
|
|
183
|
+
title: "The app's agent tools",
|
|
184
|
+
description: "The open page's own agent tools (the `agent` statics, sygnal/ai): without `call`, the tools offered now (name, description, inputSchema, annotations) and the readable context; with `call`, runs that tool with `input` under the app's rules (validation, `when`, a consequential tool asks the person in the page first).",
|
|
185
|
+
inputSchema: obj({
|
|
186
|
+
call: { type: 'string', description: 'A tool name from the list' },
|
|
187
|
+
input: { description: 'The tool input' },
|
|
188
|
+
all: { type: 'boolean', description: 'List also the declared tools that are not offered, with why' },
|
|
189
|
+
app: { ...appSchema, description: "The app whose tools to use: an index or the root component's name (see apps); default: the first" },
|
|
190
|
+
}),
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
name: 'apps',
|
|
194
|
+
title: 'Apps on the page',
|
|
195
|
+
description: "The Sygnal apps (run() calls) running in the open page: index, root component, root instance id and instance count. The first is the default of the tools' `app` argument.",
|
|
196
|
+
inputSchema: obj({}),
|
|
197
|
+
annotations: { readOnlyHint: true },
|
|
198
|
+
},
|
|
199
|
+
];
|
|
200
|
+
const TABS_TOOL = {
|
|
201
|
+
name: 'tabs',
|
|
202
|
+
title: 'Open pages',
|
|
203
|
+
description: 'The pages of this dev server connected to the endpoint (id, url, title, apps: { index, component } per run() app); the page tools use the most recently loaded or focused one unless given `tab`.',
|
|
204
|
+
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
205
|
+
annotations: { readOnlyHint: true },
|
|
206
|
+
};
|
|
207
|
+
/** sygnal-check's MCP server (its `src/mcp.js`), loaded from the project; undefined when missing */
|
|
208
|
+
async function loadCheckServer(root) {
|
|
209
|
+
try {
|
|
210
|
+
const main = node_module.createRequire(path.join(root, 'package.json')).resolve('sygnal-check');
|
|
211
|
+
const file = path.join(path.dirname(main), 'mcp.js');
|
|
212
|
+
if (!fs.existsSync(file))
|
|
213
|
+
return undefined;
|
|
214
|
+
const mod = await import(/* @vite-ignore */ node_url.pathToFileURL(file).href);
|
|
215
|
+
if (typeof mod.createMcpServer !== 'function' || !Array.isArray(mod.TOOLS))
|
|
216
|
+
return undefined;
|
|
217
|
+
return { tools: mod.TOOLS, server: mod.createMcpServer({ cwd: root }) };
|
|
218
|
+
}
|
|
219
|
+
catch (_) {
|
|
220
|
+
return undefined;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
/** The JSON-RPC side of the endpoint (transport-free; tests drive it directly) */
|
|
224
|
+
function createDevMcpServer(o) {
|
|
225
|
+
const timeout = o.timeout && o.timeout > 0 ? o.timeout : 10000;
|
|
226
|
+
const result = (id, value) => ({ jsonrpc: '2.0', id, result: value });
|
|
227
|
+
const error = (id, code, message) => ({ jsonrpc: '2.0', id, error: { code, message } });
|
|
228
|
+
let check;
|
|
229
|
+
const getCheck = async () => (check === undefined ? (check = (await o.check) || null) : check);
|
|
230
|
+
const pickTab = (want) => {
|
|
231
|
+
const tabs = o.bridge.tabs();
|
|
232
|
+
if (!tabs.length) {
|
|
233
|
+
const url = o.url?.();
|
|
234
|
+
throw new ToolError(`no page is connected: open the app${url ? ` (${url})` : ''} in a browser, with the dev server running`);
|
|
235
|
+
}
|
|
236
|
+
if (want !== undefined && want !== null) {
|
|
237
|
+
const tab = tabs.find(t => t.id === Number(want));
|
|
238
|
+
if (!tab)
|
|
239
|
+
throw new ToolError(`no tab ${want}; open tabs: ${tabs.map(t => `${t.id} ${t.url}`).join(', ')}`);
|
|
240
|
+
return { tab };
|
|
241
|
+
}
|
|
242
|
+
const tab = tabs.reduce((a, b) => (b.active > a.active ? b : a));
|
|
243
|
+
return { tab, note: tabs.length > 1 ? `${tabs.length} tabs are open; used tab ${tab.id} (the most recently loaded or focused). Pass tab to choose one (see tabs).` : undefined };
|
|
244
|
+
};
|
|
245
|
+
const content = (value, isError = false) => {
|
|
246
|
+
const structured = isObj(value) ? value : { value };
|
|
247
|
+
return { content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }], structuredContent: structured, isError };
|
|
248
|
+
};
|
|
249
|
+
async function callTool(name, args) {
|
|
250
|
+
if (name === 'tabs')
|
|
251
|
+
return content({ tabs: o.bridge.tabs().map(({ id, url, title, apps }) => ({ id, url, title, ...(apps ? { apps } : {}) })) });
|
|
252
|
+
const { tab: want, ...rest } = args;
|
|
253
|
+
const { tab, note } = pickTab(want);
|
|
254
|
+
const value = await o.bridge.request(tab.id, name, rest, timeout);
|
|
255
|
+
const out = isObj(value) ? value : { value };
|
|
256
|
+
return content({ ...out, tab: { id: tab.id, url: tab.url, title: tab.title }, ...(note ? { tabNote: note } : {}) });
|
|
257
|
+
}
|
|
258
|
+
const serverInfo = { name: 'sygnal-dev', title: 'Sygnal dev server', version: o.version || '0.0.0' };
|
|
259
|
+
async function handleMessage(msg, modern) {
|
|
260
|
+
if (!isObj(msg) || msg.jsonrpc !== '2.0' || typeof msg.method !== 'string') {
|
|
261
|
+
return isObj(msg) && 'id' in msg && !('result' in msg || 'error' in msg) ? error(msg.id ?? null, -32600, 'Invalid Request') : null;
|
|
262
|
+
}
|
|
263
|
+
if (!('id' in msg) || msg.id === null)
|
|
264
|
+
return null; // a notification
|
|
265
|
+
const { id, method } = msg;
|
|
266
|
+
const params = isObj(msg.params) ? msg.params : {};
|
|
267
|
+
// G-655: 2026-07-28 removed initialize and ping, and added server/discover
|
|
268
|
+
if (modern ? method === 'initialize' || method === 'ping' : method === 'server/discover')
|
|
269
|
+
return error(id, -32601, `Method not found: ${method}`);
|
|
270
|
+
switch (method) {
|
|
271
|
+
case 'initialize': {
|
|
272
|
+
const requested = params.protocolVersion;
|
|
273
|
+
return result(id, {
|
|
274
|
+
protocolVersion: LEGACY_VERSIONS.includes(requested) ? requested : LEGACY_VERSIONS[0],
|
|
275
|
+
capabilities: { tools: { listChanged: false } },
|
|
276
|
+
serverInfo,
|
|
277
|
+
instructions: INSTRUCTIONS,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
case 'server/discover':
|
|
281
|
+
return result(id, { supportedVersions: SUPPORTED_VERSIONS, capabilities: { tools: {} }, instructions: INSTRUCTIONS, ...CACHE });
|
|
282
|
+
case 'ping':
|
|
283
|
+
return result(id, {});
|
|
284
|
+
case 'tools/list': {
|
|
285
|
+
const c = await getCheck();
|
|
286
|
+
return result(id, { tools: [...PAGE_TOOLS, TABS_TOOL, ...(c ? c.tools : [])], ...(modern ? CACHE : {}) });
|
|
287
|
+
}
|
|
288
|
+
case 'tools/call': {
|
|
289
|
+
const name = params.name;
|
|
290
|
+
const args = isObj(params.arguments) ? params.arguments : {};
|
|
291
|
+
if (name === 'tabs' || PAGE_TOOLS.some(t => t.name === name)) {
|
|
292
|
+
try {
|
|
293
|
+
return result(id, await callTool(name, args));
|
|
294
|
+
}
|
|
295
|
+
catch (err) {
|
|
296
|
+
return result(id, { content: [{ type: 'text', text: err instanceof ToolError ? err.message : `${name} failed: ${err?.message || err}` }], isError: true });
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
const c = await getCheck();
|
|
300
|
+
if (c && c.tools.some((t) => t.name === name)) {
|
|
301
|
+
// without `_meta`, sygnal-check's server answers in the initialize era's shape (G-656: it
|
|
302
|
+
// speaks 2026-07-28 too, but this endpoint checked the envelope and adds the fields itself)
|
|
303
|
+
const { _meta, ...rest } = params;
|
|
304
|
+
return { ...c.server.handle({ ...msg, params: { ...rest, arguments: args } }), id };
|
|
305
|
+
}
|
|
306
|
+
return error(id, -32602, `Unknown tool: ${name}`);
|
|
307
|
+
}
|
|
308
|
+
default:
|
|
309
|
+
return error(id, -32601, `Method not found: ${method}`);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* One message (or a legacy batch); never throws. `modern`: a 2026-07-28 request whose envelope
|
|
314
|
+
* (version, headers, `_meta` fields) the transport checked (see mcpMiddleware); its result is
|
|
315
|
+
* marked `resultType: 'complete'` and names the server in `_meta`.
|
|
316
|
+
*/
|
|
317
|
+
async function handle(msg, modern = false) {
|
|
318
|
+
if (Array.isArray(msg)) {
|
|
319
|
+
if (modern)
|
|
320
|
+
return error(null, -32600, 'Invalid Request: one JSON-RPC message per POST');
|
|
321
|
+
const out = (await Promise.all(msg.map((m) => handle(m)))).filter(Boolean);
|
|
322
|
+
return out.length ? out : null;
|
|
323
|
+
}
|
|
324
|
+
try {
|
|
325
|
+
const out = await handleMessage(msg, modern);
|
|
326
|
+
if (modern && out && isObj(out.result)) {
|
|
327
|
+
const meta = isObj(out.result._meta) ? out.result._meta : {};
|
|
328
|
+
out.result = { resultType: 'complete', ...out.result, _meta: { ...meta, [META + 'serverInfo']: serverInfo } };
|
|
329
|
+
}
|
|
330
|
+
return out;
|
|
331
|
+
}
|
|
332
|
+
catch (err) {
|
|
333
|
+
return error(isObj(msg) && 'id' in msg ? msg.id ?? null : null, -32603, `Internal error: ${err?.message || err}`);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return { handle };
|
|
337
|
+
}
|
|
338
|
+
// ---------------------------------------------------------------- security
|
|
339
|
+
const LOOPBACK_IP = /^(127\.\d{1,3}\.\d{1,3}\.\d{1,3}|::1|::ffff:127\.\d{1,3}\.\d{1,3}\.\d{1,3})$/;
|
|
340
|
+
/** The host part of a Host header value or URL host ('[::1]:5173' → '::1') */
|
|
341
|
+
function hostname(host) {
|
|
342
|
+
const h = host.trim().toLowerCase();
|
|
343
|
+
if (h.startsWith('['))
|
|
344
|
+
return h.slice(1, h.indexOf(']') > 0 ? h.indexOf(']') : undefined);
|
|
345
|
+
const i = h.lastIndexOf(':');
|
|
346
|
+
return i > 0 && h.indexOf(':') === i ? h.slice(0, i) : h;
|
|
347
|
+
}
|
|
348
|
+
const isLocalName = (h) => h === 'localhost' || h.endsWith('.localhost') || LOOPBACK_IP.test(h);
|
|
349
|
+
/** Vite's allowedHosts entries: 'example.test', or '.example.test' for it and its subdomains */
|
|
350
|
+
function inAllowed(h, allowed) {
|
|
351
|
+
if (!Array.isArray(allowed))
|
|
352
|
+
return false;
|
|
353
|
+
return allowed.some((a) => {
|
|
354
|
+
if (typeof a !== 'string' || !a)
|
|
355
|
+
return false;
|
|
356
|
+
const e = a.toLowerCase();
|
|
357
|
+
return e.startsWith('.') ? h === e.slice(1) || h.endsWith(e) : h === e;
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
/** Why the request is refused (403), or null */
|
|
361
|
+
function refusal(req, allowedHosts) {
|
|
362
|
+
const remote = String(req?.socket?.remoteAddress || '');
|
|
363
|
+
if (!LOOPBACK_IP.test(remote))
|
|
364
|
+
return `the Sygnal MCP endpoint only answers requests from this machine (not ${remote || 'an unknown address'})`;
|
|
365
|
+
const host = req?.headers?.host;
|
|
366
|
+
if (typeof host !== 'string' || !host)
|
|
367
|
+
return 'missing Host header';
|
|
368
|
+
const h = hostname(host);
|
|
369
|
+
if (!isLocalName(h) && !inAllowed(h, allowedHosts))
|
|
370
|
+
return `Host ${host} is not a local name (add it to server.allowedHosts to allow it)`;
|
|
371
|
+
const origin = req?.headers?.origin;
|
|
372
|
+
if (origin !== undefined && origin !== 'null') {
|
|
373
|
+
let oh = '';
|
|
374
|
+
try {
|
|
375
|
+
oh = new URL(String(origin)).hostname.replace(/^\[|\]$/g, '').toLowerCase();
|
|
376
|
+
}
|
|
377
|
+
catch (_) { }
|
|
378
|
+
if (!oh || (!isLocalName(oh) && !inAllowed(oh, allowedHosts)))
|
|
379
|
+
return `Origin ${origin} is not allowed`;
|
|
380
|
+
}
|
|
381
|
+
else if (origin === 'null') {
|
|
382
|
+
return 'Origin null is not allowed';
|
|
383
|
+
}
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
// ---------------------------------------------------------------- HTTP
|
|
387
|
+
function send(res, status, body, headers = {}) {
|
|
388
|
+
res.statusCode = status;
|
|
389
|
+
for (const k in headers)
|
|
390
|
+
res.setHeader(k, headers[k]);
|
|
391
|
+
if (body === undefined)
|
|
392
|
+
return res.end();
|
|
393
|
+
res.setHeader('Content-Type', 'application/json');
|
|
394
|
+
res.end(JSON.stringify(body));
|
|
395
|
+
}
|
|
396
|
+
function readBody(req) {
|
|
397
|
+
return new Promise((resolve, reject) => {
|
|
398
|
+
let size = 0;
|
|
399
|
+
const chunks = [];
|
|
400
|
+
req.on('data', (c) => {
|
|
401
|
+
size += c.length;
|
|
402
|
+
if (size > MAX_BODY) {
|
|
403
|
+
reject(new Error('too large'));
|
|
404
|
+
req.destroy?.();
|
|
405
|
+
}
|
|
406
|
+
else
|
|
407
|
+
chunks.push(c);
|
|
408
|
+
});
|
|
409
|
+
req.on('end', () => resolve(globalThis.Buffer.concat(chunks).toString('utf8')));
|
|
410
|
+
req.on('error', reject);
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
/** An `Mcp-Name` value: plain, or the spec's `=?base64?…?=` sentinel for one that isn't header-safe */
|
|
414
|
+
function headerValue(v) {
|
|
415
|
+
const m = /^=\?base64\?(.*)\?=$/.exec(v);
|
|
416
|
+
return m ? globalThis.Buffer.from(m[1], 'base64').toString('utf8') : v;
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* G-655: why a 2026-07-28 request's envelope is refused, as [code, message], or null. The spec
|
|
420
|
+
* (basic/index "_meta", transports/streamable-http "Server Validation"): `_meta` must carry the
|
|
421
|
+
* protocol version and the client capabilities (-32602), and the MCP-Protocol-Version, Mcp-Method
|
|
422
|
+
* and (tools/call, resources/read, prompts/get) Mcp-Name headers must be there and match the body
|
|
423
|
+
* (-32020 HeaderMismatch). This server marks no parameter with `x-mcp-header`, so no
|
|
424
|
+
* Mcp-Param-* header is required.
|
|
425
|
+
*/
|
|
426
|
+
function modernEnvelope(msg, headers) {
|
|
427
|
+
const params = isObj(msg.params) ? msg.params : {};
|
|
428
|
+
const meta = isObj(params._meta) ? params._meta : {};
|
|
429
|
+
if (typeof meta[META + 'protocolVersion'] !== 'string')
|
|
430
|
+
return [-32602, `Invalid params: _meta["${META}protocolVersion"] is required`];
|
|
431
|
+
if (!isObj(meta[META + 'clientCapabilities']))
|
|
432
|
+
return [-32602, `Invalid params: _meta["${META}clientCapabilities"] is required`];
|
|
433
|
+
const header = headers['mcp-protocol-version'];
|
|
434
|
+
if (header !== meta[META + 'protocolVersion']) {
|
|
435
|
+
return [-32020, header === undefined ? 'Header mismatch: MCP-Protocol-Version header is required' : `Header mismatch: MCP-Protocol-Version header value '${header}' does not match body value '${meta[META + 'protocolVersion']}'`];
|
|
436
|
+
}
|
|
437
|
+
const method = headers['mcp-method'];
|
|
438
|
+
if (method !== msg.method)
|
|
439
|
+
return [-32020, method === undefined ? 'Header mismatch: Mcp-Method header is required' : `Header mismatch: Mcp-Method header value '${method}' does not match body value '${msg.method}'`];
|
|
440
|
+
if (msg.method === 'tools/call' || msg.method === 'prompts/get' || msg.method === 'resources/read') {
|
|
441
|
+
const want = msg.method === 'resources/read' ? params.uri : params.name;
|
|
442
|
+
const name = headers['mcp-name'];
|
|
443
|
+
if (typeof name !== 'string')
|
|
444
|
+
return [-32020, 'Header mismatch: Mcp-Name header is required'];
|
|
445
|
+
if (headerValue(name) !== want)
|
|
446
|
+
return [-32020, `Header mismatch: Mcp-Name header value '${name}' does not match body value '${want}'`];
|
|
447
|
+
}
|
|
448
|
+
return null;
|
|
449
|
+
}
|
|
450
|
+
/** The connect middleware for MCP_PATH */
|
|
451
|
+
function mcpMiddleware(server, allowedHosts) {
|
|
452
|
+
return async (req, res, next) => {
|
|
453
|
+
const url = String(req.url || '').replace(/[?#].*$/, '');
|
|
454
|
+
if (url !== MCP_PATH && url !== MCP_PATH + '/')
|
|
455
|
+
return next();
|
|
456
|
+
const why = refusal(req, allowedHosts());
|
|
457
|
+
if (why)
|
|
458
|
+
return send(res, 403, { jsonrpc: '2.0', id: null, error: { code: -32000, message: `Forbidden: ${why}` } });
|
|
459
|
+
// G-638: no SSE stream on GET (the spec's "MUST ... return 405" for a server that offers none), no sessions to DELETE
|
|
460
|
+
if (req.method !== 'POST')
|
|
461
|
+
return send(res, 405, { jsonrpc: '2.0', id: null, error: { code: -32000, message: 'Method not allowed: this endpoint takes JSON-RPC over POST and answers with application/json (no SSE stream, no sessions)' } }, { Allow: 'POST' });
|
|
462
|
+
let msg;
|
|
463
|
+
try {
|
|
464
|
+
msg = JSON.parse(await readBody(req));
|
|
465
|
+
}
|
|
466
|
+
catch (err) {
|
|
467
|
+
if (err?.message === 'too large')
|
|
468
|
+
return send(res, 413, { jsonrpc: '2.0', id: null, error: { code: -32600, message: 'Request too large' } });
|
|
469
|
+
return send(res, 400, { jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } });
|
|
470
|
+
}
|
|
471
|
+
// G-655: the era is per request: a 2026-07-28 version in the header or in `_meta` is modern
|
|
472
|
+
// (stateless), anything else the initialize era (no header: 2025-03-26)
|
|
473
|
+
const id = isObj(msg) && 'id' in msg ? msg.id ?? null : null;
|
|
474
|
+
const fail = (status, code, message, data) => send(res, status, { jsonrpc: '2.0', id, error: { code, message, ...(data ? { data } : {}) } });
|
|
475
|
+
const header = req.headers['mcp-protocol-version'];
|
|
476
|
+
const meta = isObj(msg) && isObj(msg.params) && isObj(msg.params._meta) ? msg.params._meta : {};
|
|
477
|
+
const metaVersion = meta[META + 'protocolVersion'];
|
|
478
|
+
for (const requested of [header, metaVersion]) {
|
|
479
|
+
if (requested !== undefined && !SUPPORTED_VERSIONS.includes(requested)) {
|
|
480
|
+
return fail(400, -32022, 'Unsupported protocol version', { supported: SUPPORTED_VERSIONS, requested });
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
const modern = MODERN_VERSIONS.includes(header) || MODERN_VERSIONS.includes(metaVersion);
|
|
484
|
+
if (modern) {
|
|
485
|
+
if (!isObj(msg))
|
|
486
|
+
return fail(400, -32600, 'Invalid Request: one JSON-RPC message per POST');
|
|
487
|
+
if ('id' in msg && msg.id !== null) {
|
|
488
|
+
const why = modernEnvelope(msg, req.headers);
|
|
489
|
+
if (why)
|
|
490
|
+
return fail(400, why[0], why[1]);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
const out = await server.handle(msg, modern);
|
|
494
|
+
if (out === null || out === undefined)
|
|
495
|
+
return send(res, 202);
|
|
496
|
+
// 2026-07-28: a method this server doesn't implement is 404 (the JSON-RPC body tells it from a missing endpoint)
|
|
497
|
+
send(res, modern && out.error?.code === -32601 ? 404 : 200, out);
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
// ---------------------------------------------------------------- the page bridge (Vite's HMR channel)
|
|
501
|
+
/** A PageBridge over the dev server's HMR channel (server.ws / environments.client.hot) */
|
|
502
|
+
function hmrBridge(server) {
|
|
503
|
+
const ws = server?.ws || server?.environments?.client?.hot;
|
|
504
|
+
const tabs = new Map();
|
|
505
|
+
const pending = new Map();
|
|
506
|
+
let nextTab = 1;
|
|
507
|
+
let nextReq = 1;
|
|
508
|
+
let clock = 0;
|
|
509
|
+
const fail = (client) => {
|
|
510
|
+
const t = tabs.get(client);
|
|
511
|
+
tabs.delete(client);
|
|
512
|
+
if (!t)
|
|
513
|
+
return;
|
|
514
|
+
for (const [id, p] of pending) {
|
|
515
|
+
if (p.client !== client)
|
|
516
|
+
continue;
|
|
517
|
+
clearTimeout(p.timer);
|
|
518
|
+
pending.delete(id);
|
|
519
|
+
p.reject(new ToolError(`tab ${t.id} closed before it answered`));
|
|
520
|
+
}
|
|
521
|
+
};
|
|
522
|
+
if (ws && typeof ws.on === 'function') {
|
|
523
|
+
ws.on(MCP_HELLO, (data, client) => {
|
|
524
|
+
if (!client)
|
|
525
|
+
return;
|
|
526
|
+
let t = tabs.get(client);
|
|
527
|
+
if (!t) {
|
|
528
|
+
tabs.set(client, t = { id: nextTab++, url: '', title: '', active: 0 });
|
|
529
|
+
// Vite's socket client carries its WebSocket: a closed tab leaves at once
|
|
530
|
+
try {
|
|
531
|
+
client.socket?.once?.('close', () => fail(client));
|
|
532
|
+
}
|
|
533
|
+
catch (_) { }
|
|
534
|
+
}
|
|
535
|
+
t.url = String(data?.url || '');
|
|
536
|
+
t.title = String(data?.title || '');
|
|
537
|
+
if (Array.isArray(data?.apps))
|
|
538
|
+
t.apps = data.apps.map((a) => ({ index: Number(a?.index), component: String(a?.component ?? '') }));
|
|
539
|
+
// an apps update (an HMR swap, a second run()) is not a focus
|
|
540
|
+
if (!data?.update || !t.active)
|
|
541
|
+
t.active = ++clock;
|
|
542
|
+
});
|
|
543
|
+
ws.on(MCP_RESPONSE, (data, client) => {
|
|
544
|
+
const p = data && pending.get(data.id);
|
|
545
|
+
if (!p || p.client !== client)
|
|
546
|
+
return;
|
|
547
|
+
if (data.waiting) {
|
|
548
|
+
clearTimeout(p.timer);
|
|
549
|
+
p.timer = setTimeout(() => expire(data.id), 5 * 60000);
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
clearTimeout(p.timer);
|
|
553
|
+
pending.delete(data.id);
|
|
554
|
+
if (data.ok)
|
|
555
|
+
p.resolve(data.result);
|
|
556
|
+
else
|
|
557
|
+
p.reject(new ToolError(String(data.error || 'the page reported an error')));
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
/** tabs whose socket is still open (when the channel tells) */
|
|
561
|
+
const live = () => {
|
|
562
|
+
let open;
|
|
563
|
+
try {
|
|
564
|
+
open = ws?.clients;
|
|
565
|
+
}
|
|
566
|
+
catch (_) { }
|
|
567
|
+
for (const c of [...tabs.keys()]) {
|
|
568
|
+
const closed = c.socket && typeof c.socket.readyState === 'number' && c.socket.readyState > 1;
|
|
569
|
+
if (closed || (open instanceof Set && c.socket && !open.has(c)))
|
|
570
|
+
fail(c);
|
|
571
|
+
}
|
|
572
|
+
return tabs;
|
|
573
|
+
};
|
|
574
|
+
function expire(id) {
|
|
575
|
+
const p = pending.get(id);
|
|
576
|
+
if (!p)
|
|
577
|
+
return;
|
|
578
|
+
pending.delete(id);
|
|
579
|
+
p.reject(new ToolError(`tab ${p.tab} did not answer in time (is the page frozen, or a dialog open?)`));
|
|
580
|
+
}
|
|
581
|
+
return {
|
|
582
|
+
tabs: () => [...live().values()].map(t => ({ ...t, ...(t.apps ? { apps: t.apps.map(a => ({ ...a })) } : {}) })),
|
|
583
|
+
request(tabId, tool, args, ms) {
|
|
584
|
+
const client = [...live().entries()].find(([, t]) => t.id === tabId)?.[0];
|
|
585
|
+
if (!client)
|
|
586
|
+
return Promise.reject(new ToolError(`no tab ${tabId}`));
|
|
587
|
+
const id = nextReq++;
|
|
588
|
+
return new Promise((resolve, reject) => {
|
|
589
|
+
const timer = setTimeout(() => expire(id), ms);
|
|
590
|
+
pending.set(id, { tab: tabId, resolve, reject, timer, client });
|
|
591
|
+
try {
|
|
592
|
+
client.send({ type: 'custom', event: MCP_REQUEST, data: { id, tool, args } });
|
|
593
|
+
}
|
|
594
|
+
catch (err) {
|
|
595
|
+
clearTimeout(timer);
|
|
596
|
+
pending.delete(id);
|
|
597
|
+
fail(client);
|
|
598
|
+
reject(new ToolError(`tab ${tabId} is gone: ${err?.message || err}`));
|
|
599
|
+
}
|
|
600
|
+
});
|
|
601
|
+
},
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
/** Wire the endpoint into a Vite dev server */
|
|
605
|
+
function attachMcp(server, root, options, version) {
|
|
606
|
+
const bridge = hmrBridge(server);
|
|
607
|
+
const mcp = createDevMcpServer({
|
|
608
|
+
...options,
|
|
609
|
+
bridge,
|
|
610
|
+
version,
|
|
611
|
+
check: loadCheckServer(root),
|
|
612
|
+
url: () => server?.resolvedUrls?.local?.[0],
|
|
613
|
+
});
|
|
614
|
+
server.middlewares.use(mcpMiddleware(mcp, () => server?.config?.server?.allowedHosts));
|
|
615
|
+
return mcp;
|
|
616
|
+
}
|
|
617
|
+
/** 'virtual:sygnal/mcp': connects the page (dev only) */
|
|
618
|
+
function mcpClientModule(options) {
|
|
619
|
+
const confirm = options.confirm === undefined ? 'page' : options.confirm;
|
|
620
|
+
return `import { agentTools } from 'sygnal';
|
|
621
|
+
import { installMcpBridge } from 'sygnal/devtools';
|
|
622
|
+
if (import.meta.hot) installMcpBridge(import.meta.hot, { agentTools, confirm: ${JSON.stringify(confirm)} });
|
|
623
|
+
`;
|
|
624
|
+
}
|
|
625
|
+
|
|
61
626
|
/**
|
|
62
627
|
* Sygnal Vite Plugin
|
|
63
628
|
*
|
|
@@ -101,10 +666,19 @@ function globalThisAlias(userAlias) {
|
|
|
101
666
|
* `diagnostics: { mode, ignore }` unless the call sets `diagnostics`
|
|
102
667
|
* itself. With the default ('warn', no ignore list) there is no wrapper.
|
|
103
668
|
* 5. `check` option: runs sygnal-check (an optional dependency, loaded
|
|
104
|
-
* lazily from the project
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
669
|
+
* lazily from the project) over `include` when the dev server starts and
|
|
670
|
+
* again after every source file change. Settings, highest first: the
|
|
671
|
+
* explicit `check.*` options; the project's sygnal-check config file
|
|
672
|
+
* (sygnal-check.config.json or the package.json "sygnal-check" key, read
|
|
673
|
+
* by sygnal-check itself, PLAN-7 C-5 / D299; JSON only, so the editor and
|
|
674
|
+
* the CLI see the same settings without evaluating vite.config); then
|
|
675
|
+
* `strict` from `diagnostics.strict`, `ignore` from `diagnostics.ignore`,
|
|
676
|
+
* and `include` from the existing ones of src/, pages/ and renderer/,
|
|
677
|
+
* else the project root (with a notice). An older sygnal-check without
|
|
678
|
+
* config support (no `loadConfig` export) gets exactly the options it got
|
|
679
|
+
* before: the explicit ones over the `diagnostics` defaults. With config
|
|
680
|
+
* support a change to the config file also re-checks (`include` is fixed
|
|
681
|
+
* when the server starts). Results go to
|
|
108
682
|
* the terminal in sygnal-check's format, and to the browser as a
|
|
109
683
|
* 'sygnal:check' HMR event that the dev client ('virtual:sygnal/dev')
|
|
110
684
|
* logs with console.warn (non-disruptive). The dev client asks for the
|
|
@@ -164,6 +738,10 @@ function globalThisAlias(userAlias) {
|
|
|
164
738
|
* Vitest: production builds carry no DevTools code. `devtools: false`
|
|
165
739
|
* injects nothing. PLAN-4 3-E: `devtools: { redux: true }` also calls
|
|
166
740
|
* connectReduxDevtools() (actions + state to the Redux DevTools extension).
|
|
741
|
+
* 10. `mcp` option (PLAN-6 E-1, default false): the dev server serves an MCP endpoint at
|
|
742
|
+
* /__sygnal/mcp (./mcp.ts: streamable HTTP, loopback / local Host / local Origin only)
|
|
743
|
+
* and the dev entries also import 'virtual:sygnal/mcp', which connects the page over the
|
|
744
|
+
* HMR channel (installMcpBridge from 'sygnal/devtools'). Never in `vite build` or Vitest.
|
|
167
745
|
*
|
|
168
746
|
* Why not Vite's `define`? Vite's dependency optimizer does not apply user
|
|
169
747
|
* `define` replacements to pre-bundled dependencies (only process.env.NODE_ENV),
|
|
@@ -204,6 +782,9 @@ const CHECK_EVENT = 'sygnal:check';
|
|
|
204
782
|
// Sent by the dev client when it loads: the server answers that client only
|
|
205
783
|
const CHECK_REQUEST = 'sygnal:check:request';
|
|
206
784
|
const OVERLAY_PLUGIN = 'sygnal-check';
|
|
785
|
+
// PLAN-6 E-1: the page side of the dev MCP endpoint
|
|
786
|
+
const MCP_CLIENT = 'virtual:sygnal/mcp';
|
|
787
|
+
const MCP_CLIENT_ID = '\0' + MCP_CLIENT;
|
|
207
788
|
// Register the runtime checks with the core that loaded last (see the Vike wrapper)
|
|
208
789
|
const REINSTALL_IMPORT = `import { installChecks as __sygnalInstallChecks } from 'sygnal/diagnostics';`;
|
|
209
790
|
const REINSTALL_CALL = 'try { __sygnalInstallChecks() } catch (e) { console.warn(e) }\n';
|
|
@@ -222,11 +803,14 @@ function sygnal(options = {}) {
|
|
|
222
803
|
const flags = DEV_FLAG + (diagnostics.strict ? STRICT_FLAG : '');
|
|
223
804
|
const devImports = `import 'sygnal/diagnostics';import '${DEV_CLIENT}';`;
|
|
224
805
|
// D77: the DevTools bridge, dev only. First, so the diagnostics entry finds it.
|
|
225
|
-
|
|
806
|
+
// E-1: the MCP endpoint needs the bridge, so `mcp` turns it on
|
|
807
|
+
const mcpOn = !!options.mcp;
|
|
808
|
+
const mcpOptions = typeof options.mcp === 'object' && options.mcp ? options.mcp : {};
|
|
809
|
+
const devtoolsOn = options.devtools !== false || mcpOn;
|
|
226
810
|
const redux = typeof options.devtools == 'object' && !!options.devtools?.redux;
|
|
227
|
-
const devtoolsImport = !devtoolsOn ? ''
|
|
811
|
+
const devtoolsImport = (!devtoolsOn ? ''
|
|
228
812
|
: redux ? `import { connectReduxDevtools as __sygnalReduxDevtools } from 'sygnal/devtools';__sygnalReduxDevtools();`
|
|
229
|
-
: `import 'sygnal/devtools'
|
|
813
|
+
: `import 'sygnal/devtools';`) + (mcpOn ? `import '${MCP_CLIENT}';` : '');
|
|
230
814
|
// What a dev entry gets: DevTools, then (unless diagnostics are 'off') flags + checks
|
|
231
815
|
const devSnippet = devtoolsImport + (devOn ? flags + devImports : '');
|
|
232
816
|
const devInject = devOn || devtoolsOn;
|
|
@@ -339,6 +923,8 @@ function sygnal(options = {}) {
|
|
|
339
923
|
return null;
|
|
340
924
|
if (source === DEV_CLIENT)
|
|
341
925
|
return DEV_CLIENT_ID;
|
|
926
|
+
if (source === MCP_CLIENT && mcpOn)
|
|
927
|
+
return MCP_CLIENT_ID;
|
|
342
928
|
if (source === 'sygnal' && importer && wrapRun && runtimeImporters.has(cleanId(importer))) {
|
|
343
929
|
return RUNTIME_ID;
|
|
344
930
|
}
|
|
@@ -359,6 +945,8 @@ function sygnal(options = {}) {
|
|
|
359
945
|
load(id) {
|
|
360
946
|
if (id === DEV_CLIENT_ID)
|
|
361
947
|
return devClientModule();
|
|
948
|
+
if (id === MCP_CLIENT_ID && mcpOn)
|
|
949
|
+
return mcpClientModule(mcpOptions);
|
|
362
950
|
if (id === RUNTIME_ID)
|
|
363
951
|
return runtimeModule(diagnostics);
|
|
364
952
|
// The checks register again once the real entry has loaded: a
|
|
@@ -372,7 +960,13 @@ function sygnal(options = {}) {
|
|
|
372
960
|
return null;
|
|
373
961
|
},
|
|
374
962
|
configureServer(server) {
|
|
375
|
-
if (isVitest
|
|
963
|
+
if (isVitest)
|
|
964
|
+
return;
|
|
965
|
+
// E-1: the MCP endpoint (a middleware ahead of Vite's own; it does its own host checks)
|
|
966
|
+
if (mcpOn && server?.middlewares) {
|
|
967
|
+
attachMcp(server, root, mcpOptions, sygnalVersion(root));
|
|
968
|
+
}
|
|
969
|
+
if (options.check === false)
|
|
376
970
|
return;
|
|
377
971
|
const checkOptions = typeof options.check === 'object' && options.check ? options.check : {};
|
|
378
972
|
// Deferred: the dev server doesn't wait for the first check
|
|
@@ -647,6 +1241,16 @@ function sygnalPackageDir(root) {
|
|
|
647
1241
|
catch (_) { }
|
|
648
1242
|
return undefined;
|
|
649
1243
|
}
|
|
1244
|
+
/** The version of the 'sygnal' package the project resolves */
|
|
1245
|
+
function sygnalVersion(root) {
|
|
1246
|
+
const dir = sygnalPackageDir(root);
|
|
1247
|
+
try {
|
|
1248
|
+
return dir ? JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')).version : undefined;
|
|
1249
|
+
}
|
|
1250
|
+
catch (_) {
|
|
1251
|
+
return undefined;
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
650
1254
|
/** Real paths of sygnal's 'astro/client' ESM file. */
|
|
651
1255
|
function astroClientFiles(root) {
|
|
652
1256
|
const files = new Set();
|
|
@@ -714,6 +1318,8 @@ async function loadSygnalCheck(root) {
|
|
|
714
1318
|
}
|
|
715
1319
|
}
|
|
716
1320
|
const SOURCE_RE = /\.[cm]?[jt]sx?$/;
|
|
1321
|
+
// sygnal-check's config sources (PLAN-7 C-5): a change re-checks
|
|
1322
|
+
const CONFIG_FILES = ['sygnal-check.config.json', 'package.json'];
|
|
717
1323
|
/** sygnal-check's one-line format: `file:line:col CODE [severity] Component: message (fix)` */
|
|
718
1324
|
function formatLine(d) {
|
|
719
1325
|
const where = d.file ? `${d.file}:${d.line}:${d.column}` : '<sygnal-check>';
|
|
@@ -730,11 +1336,11 @@ const DEFAULT_INCLUDE = ['src', 'pages', 'renderer'];
|
|
|
730
1336
|
* directory, or when none of the given paths exists, so an empty check is
|
|
731
1337
|
* never a silent all-clear.
|
|
732
1338
|
*/
|
|
733
|
-
function checkInclude(root, include, notice) {
|
|
1339
|
+
function checkInclude(root, include, notice, label = 'check.include') {
|
|
734
1340
|
const exists = (p) => /[*?[{]/.test(p) || fs.existsSync(path.resolve(root, p));
|
|
735
1341
|
if (include && include.length) {
|
|
736
1342
|
if (!include.some(exists))
|
|
737
|
-
notice(`[sygnal] sygnal-check: none of
|
|
1343
|
+
notice(`[sygnal] sygnal-check: none of ${label} (${include.map(p => path.isAbsolute(p) ? path.relative(root, p) || '.' : p).join(', ')}) exists under ${root}, so nothing is checked`);
|
|
738
1344
|
return include;
|
|
739
1345
|
}
|
|
740
1346
|
const dirs = DEFAULT_INCLUDE.filter(exists);
|
|
@@ -752,11 +1358,43 @@ async function startChecker(server, root, opts, defaults, explicit) {
|
|
|
752
1358
|
logger.info('[sygnal] sygnal-check is not installed, so static checks are off (npm i -D sygnal-check)');
|
|
753
1359
|
return;
|
|
754
1360
|
}
|
|
755
|
-
const
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
//
|
|
759
|
-
const
|
|
1361
|
+
const notice = (m) => logger.info(m);
|
|
1362
|
+
// PLAN-7 C-5 (D299): a sygnal-check with config support resolves the settings itself:
|
|
1363
|
+
// explicit check.* options > its config file > the `diagnostics` defaults passed as `defaults`.
|
|
1364
|
+
// An older one gets exactly what it got before.
|
|
1365
|
+
const configAware = typeof mod.loadConfig === 'function';
|
|
1366
|
+
let include;
|
|
1367
|
+
let checkOptions;
|
|
1368
|
+
if (configAware) {
|
|
1369
|
+
let configPaths;
|
|
1370
|
+
if (!(opts.include && opts.include.length)) {
|
|
1371
|
+
try {
|
|
1372
|
+
configPaths = mod.loadConfig(root)?.config?.paths;
|
|
1373
|
+
}
|
|
1374
|
+
catch (_) { }
|
|
1375
|
+
}
|
|
1376
|
+
include = configPaths && configPaths.length
|
|
1377
|
+
? checkInclude(root, configPaths, notice, "the config file's paths")
|
|
1378
|
+
: checkInclude(root, opts.include, notice);
|
|
1379
|
+
checkOptions = {
|
|
1380
|
+
cwd: root,
|
|
1381
|
+
strict: opts.strict === undefined ? undefined : !!opts.strict,
|
|
1382
|
+
ignore: opts.ignore,
|
|
1383
|
+
a11y: opts.a11y === 'error' || opts.a11y === 'warn' ? opts.a11y : undefined,
|
|
1384
|
+
defaults: { strict: defaults.strict, ignore: defaults.ignore },
|
|
1385
|
+
};
|
|
1386
|
+
}
|
|
1387
|
+
else {
|
|
1388
|
+
include = checkInclude(root, opts.include, notice);
|
|
1389
|
+
// D144: the a11y lane is a warning unless asked for as an error, under strict too
|
|
1390
|
+
checkOptions = {
|
|
1391
|
+
cwd: root,
|
|
1392
|
+
strict: opts.strict === undefined ? defaults.strict : !!opts.strict,
|
|
1393
|
+
ignore: opts.ignore || defaults.ignore,
|
|
1394
|
+
a11y: opts.a11y === 'error' ? 'error' : 'warn',
|
|
1395
|
+
};
|
|
1396
|
+
}
|
|
1397
|
+
const a11y = checkOptions.a11y;
|
|
760
1398
|
// Only error-severity findings open Vite's overlay: while an overlay is open
|
|
761
1399
|
// Vite's client reloads the page on the first HMR update, so warnings stay
|
|
762
1400
|
// in the terminal and the browser console. overlay: 'warn' is treated as
|
|
@@ -787,14 +1425,15 @@ async function startChecker(server, root, opts, defaults, explicit) {
|
|
|
787
1425
|
const run = (initial = false) => {
|
|
788
1426
|
let diags;
|
|
789
1427
|
try {
|
|
790
|
-
diags = mod.check(include,
|
|
1428
|
+
diags = mod.check(include, checkOptions);
|
|
791
1429
|
}
|
|
792
1430
|
catch (err) {
|
|
793
1431
|
logger.warn(`[sygnal] sygnal-check failed: ${err?.message || err}`, { timestamp: true });
|
|
794
1432
|
return;
|
|
795
1433
|
}
|
|
796
1434
|
// A sygnal-check from before D144 reports SYG7xx as errors under strict: keep them warnings
|
|
797
|
-
|
|
1435
|
+
// (one with config support postdates D144 and resolves a11y itself)
|
|
1436
|
+
if (!configAware && a11y !== 'error') {
|
|
798
1437
|
diags = diags.map(d => d && d.severity === 'error' && /^SYG7\d\d$/.test(d.code) ? { ...d, severity: 'warn' } : d);
|
|
799
1438
|
}
|
|
800
1439
|
const shown = diags.filter(d => d.severity !== 'info');
|
|
@@ -848,7 +1487,9 @@ async function startChecker(server, root, opts, defaults, explicit) {
|
|
|
848
1487
|
catch (_) { }
|
|
849
1488
|
let timer;
|
|
850
1489
|
const onChange = (file) => {
|
|
851
|
-
if (
|
|
1490
|
+
if (file.split(/[\\/]/).includes('node_modules'))
|
|
1491
|
+
return;
|
|
1492
|
+
if (!SOURCE_RE.test(file) && !(configAware && CONFIG_FILES.includes(path.basename(file))))
|
|
852
1493
|
return;
|
|
853
1494
|
clearTimeout(timer);
|
|
854
1495
|
timer = setTimeout(run, 100);
|