@vaultgradient/pipequery-cli 0.3.3 → 0.6.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.
@@ -0,0 +1,191 @@
1
+ import { createServer as createHttpServer } from 'node:http';
2
+ import type { IncomingMessage, ServerResponse } from 'node:http';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
5
+ import type { Provider } from './provider.js';
6
+ import { createMcpServer, type McpServerInfo } from './server.js';
7
+
8
+ export interface HttpServerOptions {
9
+ host?: string;
10
+ port: number;
11
+ path?: string;
12
+ info?: McpServerInfo;
13
+ /**
14
+ * Require clients to send `Authorization: Bearer <token>` matching this
15
+ * value. When undefined, the endpoint is UNAUTHENTICATED and the server
16
+ * prints a loud stderr warning at startup. Intended for localhost /
17
+ * trusted-network deployments only when auth is unset.
18
+ *
19
+ * For hosted deployments, always set this (via `--auth-token` flag or
20
+ * the `PIPEQUERY_MCP_TOKEN` environment variable).
21
+ */
22
+ authToken?: string;
23
+ }
24
+
25
+ export interface HttpServerHandle {
26
+ close(): Promise<void>;
27
+ url: string;
28
+ }
29
+
30
+ /**
31
+ * Stand up a standalone HTTP server hosting the MCP Streamable HTTP transport.
32
+ *
33
+ * Stateful mode: each client gets a session ID on its first (initialize)
34
+ * request and reuses it for subsequent tool calls. Sessions are in-memory;
35
+ * this is single-instance by design. Multi-tenant / horizontally-scaled
36
+ * hosting is out of scope for Phase 0.
37
+ */
38
+ export async function runHttp(provider: Provider, opts: HttpServerOptions): Promise<HttpServerHandle> {
39
+ const host = opts.host ?? '127.0.0.1';
40
+ const port = opts.port;
41
+ const mountPath = opts.path ?? '/mcp';
42
+
43
+ const transports = new Map<string, StreamableHTTPServerTransport>();
44
+ const authToken = opts.authToken;
45
+
46
+ if (!authToken) {
47
+ process.stderr.write(
48
+ '[pipequery-mcp] WARNING: HTTP endpoint is UNAUTHENTICATED. ' +
49
+ 'Set --auth-token or PIPEQUERY_MCP_TOKEN for anything beyond localhost.\n',
50
+ );
51
+ }
52
+
53
+ const attachTransport = async (): Promise<StreamableHTTPServerTransport> => {
54
+ const transport = new StreamableHTTPServerTransport({
55
+ sessionIdGenerator: () => randomUUID(),
56
+ onsessioninitialized: (sessionId) => {
57
+ transports.set(sessionId, transport);
58
+ },
59
+ onsessionclosed: (sessionId) => {
60
+ transports.delete(sessionId);
61
+ },
62
+ });
63
+ const mcp = createMcpServer(provider, opts.info);
64
+ await mcp.connect(transport);
65
+ return transport;
66
+ };
67
+
68
+ const server = createHttpServer(async (req, res) => {
69
+ if (!req.url) {
70
+ res.statusCode = 400;
71
+ res.end('Bad Request');
72
+ return;
73
+ }
74
+
75
+ const requestPath = req.url.split('?')[0];
76
+ if (requestPath !== mountPath) {
77
+ res.statusCode = 404;
78
+ res.end('Not Found');
79
+ return;
80
+ }
81
+
82
+ // Bearer-token auth check (only when a token is configured).
83
+ if (authToken && !isAuthorized(req, authToken)) {
84
+ res.statusCode = 401;
85
+ res.setHeader('content-type', 'application/json');
86
+ res.setHeader('www-authenticate', 'Bearer realm="pipequery-mcp"');
87
+ res.end(
88
+ JSON.stringify({
89
+ jsonrpc: '2.0',
90
+ error: { code: -32001, message: 'Unauthorized: missing or invalid bearer token' },
91
+ id: null,
92
+ }),
93
+ );
94
+ return;
95
+ }
96
+
97
+ try {
98
+ const body = await readJsonBody(req);
99
+ const sessionId = getSessionId(req);
100
+ let transport = sessionId ? transports.get(sessionId) : undefined;
101
+ if (!transport) {
102
+ // No session yet — this should be an initialize request. Mint a new transport.
103
+ transport = await attachTransport();
104
+ }
105
+ await transport.handleRequest(req, res, body);
106
+ } catch (err) {
107
+ process.stderr.write(
108
+ `[pipequery-mcp] transport error: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`,
109
+ );
110
+ if (!res.headersSent) {
111
+ res.statusCode = 500;
112
+ res.setHeader('content-type', 'application/json');
113
+ res.end(JSON.stringify({
114
+ jsonrpc: '2.0',
115
+ error: {
116
+ code: -32603,
117
+ message: err instanceof Error ? err.message : String(err),
118
+ },
119
+ id: null,
120
+ }));
121
+ } else {
122
+ res.end();
123
+ }
124
+ }
125
+ });
126
+
127
+ await new Promise<void>((resolve) => server.listen(port, host, resolve));
128
+
129
+ return {
130
+ url: `http://${host}:${port}${mountPath}`,
131
+ async close() {
132
+ await new Promise<void>((resolve, reject) => {
133
+ server.close((err) => (err ? reject(err) : resolve()));
134
+ });
135
+ for (const t of transports.values()) {
136
+ await t.close();
137
+ }
138
+ transports.clear();
139
+ await provider.dispose();
140
+ },
141
+ };
142
+ }
143
+
144
+ function isAuthorized(req: IncomingMessage, expected: string): boolean {
145
+ const header = req.headers['authorization'];
146
+ const raw = typeof header === 'string' ? header : Array.isArray(header) ? header[0] : undefined;
147
+ if (!raw) return false;
148
+ // Format: "Bearer <token>", case-insensitive scheme.
149
+ const match = raw.match(/^Bearer\s+(.+)$/i);
150
+ if (!match) return false;
151
+ const token = match[1].trim();
152
+ // Constant-time comparison — avoids timing oracles on short tokens.
153
+ return timingSafeEqual(token, expected);
154
+ }
155
+
156
+ function timingSafeEqual(a: string, b: string): boolean {
157
+ if (a.length !== b.length) return false;
158
+ let diff = 0;
159
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
160
+ return diff === 0;
161
+ }
162
+
163
+ function getSessionId(req: IncomingMessage): string | undefined {
164
+ const header = req.headers['mcp-session-id'];
165
+ if (typeof header === 'string') return header;
166
+ if (Array.isArray(header)) return header[0];
167
+ return undefined;
168
+ }
169
+
170
+ async function readJsonBody(req: IncomingMessage): Promise<unknown> {
171
+ const method = req.method?.toUpperCase();
172
+ if (method === 'GET' || method === 'DELETE') return undefined;
173
+
174
+ const chunks: Buffer[] = [];
175
+ for await (const chunk of req) {
176
+ chunks.push(chunk as Buffer);
177
+ }
178
+ if (chunks.length === 0) return undefined;
179
+
180
+ const text = Buffer.concat(chunks).toString('utf8');
181
+ if (!text.trim()) return undefined;
182
+
183
+ try {
184
+ return JSON.parse(text);
185
+ } catch {
186
+ throw new Error('Invalid JSON body');
187
+ }
188
+ }
189
+
190
+ // Suppress unused-import warning in some configurations; ServerResponse is part of the signature readers care about.
191
+ export type { ServerResponse };
@@ -0,0 +1,223 @@
1
+ import type { DataContext } from '../engine.js';
2
+ import type { EndpointConfig, PipeQueryConfig } from '../config/schema.js';
3
+ import type { SourceStatus } from '../server/sources/types.js';
4
+ import { SourceManager } from '../server/sources/manager.js';
5
+ import { query } from '../engine.js';
6
+
7
+ export interface ProviderSourceInfo {
8
+ name: string;
9
+ status: SourceStatus;
10
+ }
11
+
12
+ export interface ProviderSourceDescription {
13
+ name: string;
14
+ status: SourceStatus;
15
+ sample: unknown[];
16
+ fields: string[];
17
+ }
18
+
19
+ export interface Provider {
20
+ listSources(): Promise<ProviderSourceInfo[]>;
21
+ describeSource(name: string, sampleSize?: number): Promise<ProviderSourceDescription | undefined>;
22
+ listEndpoints(): Promise<Array<{ path: string; config: EndpointConfig }>>;
23
+ callEndpoint(path: string): Promise<unknown>;
24
+ runQuery(expression: string): Promise<unknown>;
25
+ dispose(): Promise<void>;
26
+ }
27
+
28
+ // ─── LocalProvider ──────────────────────────────────────────────────────────
29
+ // Boots its own SourceManager from a loaded config. Used when `pq mcp serve`
30
+ // runs standalone (no attached `pq serve` daemon).
31
+
32
+ export class LocalProvider implements Provider {
33
+ private sourceManager: SourceManager;
34
+ private endpoints: Map<string, EndpointConfig>;
35
+ private ready: Promise<void>;
36
+
37
+ constructor(private config: PipeQueryConfig, cwd: string) {
38
+ this.sourceManager = new SourceManager(cwd);
39
+ this.endpoints = new Map(Object.entries(config.endpoints));
40
+ this.ready = this.bootSources();
41
+ }
42
+
43
+ private async bootSources(): Promise<void> {
44
+ for (const [name, src] of Object.entries(this.config.sources)) {
45
+ try {
46
+ await this.sourceManager.addSource(name, src);
47
+ } catch (err) {
48
+ const msg = err instanceof Error ? err.message : String(err);
49
+ // Print to stderr; stdout is reserved for the MCP JSON-RPC stream in stdio mode.
50
+ process.stderr.write(`[pipequery-mcp] Source "${name}" failed to start: ${msg}\n`);
51
+ }
52
+ }
53
+ }
54
+
55
+ async listSources(): Promise<ProviderSourceInfo[]> {
56
+ await this.ready;
57
+ const statuses = this.sourceManager.getAllStatuses();
58
+ return Object.entries(statuses).map(([name, status]) => ({ name, status }));
59
+ }
60
+
61
+ async describeSource(name: string, sampleSize = 5): Promise<ProviderSourceDescription | undefined> {
62
+ await this.ready;
63
+ const status = this.sourceManager.getSourceStatus(name);
64
+ const data = this.sourceManager.getSourceData(name);
65
+ if (!status || !data) return undefined;
66
+
67
+ const sample = data.slice(0, sampleSize);
68
+ const fields = inferFields(data);
69
+ return { name, status, sample, fields };
70
+ }
71
+
72
+ async listEndpoints(): Promise<Array<{ path: string; config: EndpointConfig }>> {
73
+ await this.ready;
74
+ return [...this.endpoints].map(([path, config]) => ({ path, config }));
75
+ }
76
+
77
+ async callEndpoint(path: string): Promise<unknown> {
78
+ await this.ready;
79
+ const endpoint = this.endpoints.get(path);
80
+ if (!endpoint) {
81
+ throw new Error(`Endpoint "${path}" not found`);
82
+ }
83
+ const context = this.sourceManager.getContext();
84
+ return query(context, endpoint.query);
85
+ }
86
+
87
+ async runQuery(expression: string): Promise<unknown> {
88
+ await this.ready;
89
+ const context = this.sourceManager.getContext();
90
+ return query(context, expression);
91
+ }
92
+
93
+ async dispose(): Promise<void> {
94
+ await this.sourceManager.dispose();
95
+ }
96
+
97
+ // Exposed for the HTTP plugin that wants to mount the MCP server into an
98
+ // existing `createServer` instance; the caller passes in their own manager.
99
+ static withSourceManager(sourceManager: SourceManager, endpoints: Map<string, EndpointConfig>): Provider {
100
+ return new AttachedLocalProvider(sourceManager, endpoints);
101
+ }
102
+ }
103
+
104
+ class AttachedLocalProvider implements Provider {
105
+ constructor(private sourceManager: SourceManager, private endpoints: Map<string, EndpointConfig>) {}
106
+
107
+ async listSources(): Promise<ProviderSourceInfo[]> {
108
+ const statuses = this.sourceManager.getAllStatuses();
109
+ return Object.entries(statuses).map(([name, status]) => ({ name, status }));
110
+ }
111
+
112
+ async describeSource(name: string, sampleSize = 5): Promise<ProviderSourceDescription | undefined> {
113
+ const status = this.sourceManager.getSourceStatus(name);
114
+ const data = this.sourceManager.getSourceData(name);
115
+ if (!status || !data) return undefined;
116
+ return { name, status, sample: data.slice(0, sampleSize), fields: inferFields(data) };
117
+ }
118
+
119
+ async listEndpoints() {
120
+ return [...this.endpoints].map(([path, config]) => ({ path, config }));
121
+ }
122
+
123
+ async callEndpoint(path: string): Promise<unknown> {
124
+ const endpoint = this.endpoints.get(path);
125
+ if (!endpoint) throw new Error(`Endpoint "${path}" not found`);
126
+ return query(this.sourceManager.getContext(), endpoint.query);
127
+ }
128
+
129
+ async runQuery(expression: string): Promise<unknown> {
130
+ return query(this.sourceManager.getContext(), expression);
131
+ }
132
+
133
+ async dispose(): Promise<void> {
134
+ // Intentionally noop: the owning server disposes the SourceManager.
135
+ }
136
+ }
137
+
138
+ // ─── AttachedProvider ───────────────────────────────────────────────────────
139
+ // Talks to a running `pq serve` via its existing /api/_control/* endpoints.
140
+
141
+ export class AttachedProvider implements Provider {
142
+ constructor(private baseUrl: string) {
143
+ this.baseUrl = baseUrl.replace(/\/$/, '');
144
+ }
145
+
146
+ private async get<T>(path: string): Promise<T> {
147
+ const res = await fetch(`${this.baseUrl}${path}`);
148
+ if (!res.ok) throw new Error(`GET ${path} failed: HTTP ${res.status}`);
149
+ return res.json() as Promise<T>;
150
+ }
151
+
152
+ private async post<T>(path: string, body: unknown): Promise<T> {
153
+ const res = await fetch(`${this.baseUrl}${path}`, {
154
+ method: 'POST',
155
+ headers: { 'content-type': 'application/json' },
156
+ body: JSON.stringify(body),
157
+ });
158
+ if (!res.ok) throw new Error(`POST ${path} failed: HTTP ${res.status}`);
159
+ return res.json() as Promise<T>;
160
+ }
161
+
162
+ async listSources(): Promise<ProviderSourceInfo[]> {
163
+ const statuses = await this.get<Record<string, SourceStatus>>('/api/_control/sources');
164
+ return Object.entries(statuses).map(([name, status]) => ({ name, status }));
165
+ }
166
+
167
+ async describeSource(name: string, sampleSize = 5): Promise<ProviderSourceDescription | undefined> {
168
+ const res = await this.post<{
169
+ ok: boolean;
170
+ error?: string;
171
+ rowCount?: number;
172
+ sample?: unknown[];
173
+ status?: SourceStatus;
174
+ }>(`/api/_control/sources/${encodeURIComponent(name)}/test`, {});
175
+ if (!res.ok || !res.status || !res.sample) return undefined;
176
+ return {
177
+ name,
178
+ status: res.status,
179
+ sample: res.sample.slice(0, sampleSize),
180
+ fields: inferFields(res.sample),
181
+ };
182
+ }
183
+
184
+ async listEndpoints() {
185
+ const endpoints = await this.get<Record<string, EndpointConfig>>('/api/_control/endpoints');
186
+ return Object.entries(endpoints).map(([path, config]) => ({ path, config }));
187
+ }
188
+
189
+ async callEndpoint(path: string): Promise<unknown> {
190
+ // Just hit the actual public endpoint on the running server.
191
+ const normalized = path.startsWith('/') ? path : `/${path}`;
192
+ const res = await fetch(`${this.baseUrl}${normalized}`);
193
+ if (!res.ok) throw new Error(`GET ${normalized} failed: HTTP ${res.status}`);
194
+ return res.json();
195
+ }
196
+
197
+ async runQuery(expression: string): Promise<unknown> {
198
+ const res = await this.post<{ ok: boolean; result?: unknown; error?: string }>(
199
+ '/api/_control/query',
200
+ { query: expression },
201
+ );
202
+ if (!res.ok) throw new Error(res.error ?? 'Query failed');
203
+ return res.result;
204
+ }
205
+
206
+ async dispose(): Promise<void> {
207
+ // Stateless — nothing to clean up.
208
+ }
209
+ }
210
+
211
+ // ─── Helpers ────────────────────────────────────────────────────────────────
212
+
213
+ function inferFields(rows: unknown[]): string[] {
214
+ const fields = new Set<string>();
215
+ for (const row of rows.slice(0, 20)) {
216
+ if (row && typeof row === 'object' && !Array.isArray(row)) {
217
+ for (const k of Object.keys(row)) fields.add(k);
218
+ }
219
+ }
220
+ return [...fields];
221
+ }
222
+
223
+ export type { DataContext };
@@ -0,0 +1,138 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { z } from 'zod';
3
+ import type { Provider } from './provider.js';
4
+
5
+ export interface McpServerInfo {
6
+ name: string;
7
+ version: string;
8
+ }
9
+
10
+ const DEFAULT_INFO: McpServerInfo = {
11
+ name: 'pipequery',
12
+ version: '0.1.0',
13
+ };
14
+
15
+ /**
16
+ * Build an MCP server wired to the supplied provider.
17
+ *
18
+ * Tools exposed:
19
+ * - query run a PipeQuery expression against live sources
20
+ * - list_sources list configured sources and their health
21
+ * - describe_source return a sample + inferred field names for a source
22
+ * - list_endpoints list pre-configured endpoints and their stored queries
23
+ * - call_endpoint execute a pre-configured endpoint by path
24
+ */
25
+ export function createMcpServer(provider: Provider, info: McpServerInfo = DEFAULT_INFO): McpServer {
26
+ const mcp = new McpServer(info);
27
+
28
+ mcp.registerTool(
29
+ 'query',
30
+ {
31
+ title: 'Run a PipeQuery expression',
32
+ description:
33
+ 'Execute a PipeQuery pipe-based expression (e.g. `crypto | where(price > 100) | sort(price desc) | first(5)`) against all currently configured sources. Returns the computed result as JSON.',
34
+ inputSchema: {
35
+ expression: z
36
+ .string()
37
+ .min(1)
38
+ .describe('The PipeQuery expression. Source names used in the expression must be configured; call `list_sources` to see them.'),
39
+ },
40
+ },
41
+ async ({ expression }) => {
42
+ const result = await provider.runQuery(expression);
43
+ return toolResult(result);
44
+ },
45
+ );
46
+
47
+ mcp.registerTool(
48
+ 'list_sources',
49
+ {
50
+ title: 'List configured data sources',
51
+ description:
52
+ 'List every data source configured in pipequery.yaml along with its current health, row count, and last-fetch timestamp.',
53
+ },
54
+ async () => {
55
+ const sources = await provider.listSources();
56
+ return toolResult({ count: sources.length, sources });
57
+ },
58
+ );
59
+
60
+ mcp.registerTool(
61
+ 'describe_source',
62
+ {
63
+ title: 'Describe a data source',
64
+ description:
65
+ 'Return a sample of rows and the inferred field names for a single source. Useful before constructing a query.',
66
+ inputSchema: {
67
+ name: z.string().min(1).describe('Source name as returned by list_sources.'),
68
+ sample_size: z
69
+ .number()
70
+ .int()
71
+ .positive()
72
+ .max(100)
73
+ .optional()
74
+ .describe('Number of rows to include in the sample. Defaults to 5, max 100.'),
75
+ },
76
+ },
77
+ async ({ name, sample_size }) => {
78
+ const description = await provider.describeSource(name, sample_size);
79
+ if (!description) {
80
+ return toolError(`Source "${name}" not found`);
81
+ }
82
+ return toolResult(description);
83
+ },
84
+ );
85
+
86
+ mcp.registerTool(
87
+ 'list_endpoints',
88
+ {
89
+ title: 'List pre-configured query endpoints',
90
+ description:
91
+ 'Return every endpoint declared in pipequery.yaml along with its stored query expression. These endpoints can be invoked via `call_endpoint`.',
92
+ },
93
+ async () => {
94
+ const endpoints = await provider.listEndpoints();
95
+ return toolResult({ count: endpoints.length, endpoints });
96
+ },
97
+ );
98
+
99
+ mcp.registerTool(
100
+ 'call_endpoint',
101
+ {
102
+ title: 'Invoke a pre-configured endpoint',
103
+ description:
104
+ 'Execute the stored query for an endpoint path (e.g. `/api/top-coins`) and return its JSON result. Equivalent to the HTTP endpoint on `pq serve`, without needing a live HTTP call.',
105
+ inputSchema: {
106
+ path: z.string().min(1).describe('Endpoint path, exactly as declared in pipequery.yaml (typically begins with `/`).'),
107
+ },
108
+ },
109
+ async ({ path }) => {
110
+ try {
111
+ const result = await provider.callEndpoint(path);
112
+ return toolResult(result);
113
+ } catch (err) {
114
+ return toolError(err instanceof Error ? err.message : String(err));
115
+ }
116
+ },
117
+ );
118
+
119
+ return mcp;
120
+ }
121
+
122
+ // ─── Helpers ────────────────────────────────────────────────────────────────
123
+
124
+ function toolResult(data: unknown): { content: Array<{ type: 'text'; text: string }> } {
125
+ return {
126
+ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
127
+ };
128
+ }
129
+
130
+ function toolError(message: string): {
131
+ content: Array<{ type: 'text'; text: string }>;
132
+ isError: true;
133
+ } {
134
+ return {
135
+ content: [{ type: 'text', text: message }],
136
+ isError: true,
137
+ };
138
+ }
@@ -0,0 +1,26 @@
1
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
2
+ import type { Provider } from './provider.js';
3
+ import { createMcpServer, type McpServerInfo } from './server.js';
4
+
5
+ export async function runStdio(provider: Provider, info?: McpServerInfo): Promise<void> {
6
+ const mcp = createMcpServer(provider, info);
7
+ const transport = new StdioServerTransport();
8
+ await mcp.connect(transport);
9
+
10
+ // Clean up on stdio close — Claude Desktop etc. close the child process
11
+ // by closing its stdin.
12
+ const cleanup = async () => {
13
+ try {
14
+ await mcp.close();
15
+ } finally {
16
+ await provider.dispose();
17
+ }
18
+ };
19
+
20
+ process.stdin.on('close', () => {
21
+ void cleanup().then(() => process.exit(0));
22
+ });
23
+
24
+ process.on('SIGINT', () => void cleanup().then(() => process.exit(0)));
25
+ process.on('SIGTERM', () => void cleanup().then(() => process.exit(0)));
26
+ }
package/src/server/app.ts CHANGED
@@ -2,6 +2,7 @@ import Fastify from 'fastify';
2
2
  import cors from '@fastify/cors';
3
3
  import type { PipeQueryConfig, EndpointConfig, SourceConfig } from '../config/schema.js';
4
4
  import { SourceManager } from './sources/manager.js';
5
+ import { WatchManager } from './watches/manager.js';
5
6
  import { registerHealthRoutes } from './routes/health.js';
6
7
  import { registerControlRoutes } from './routes/control.js';
7
8
  import { registerDynamicRoutes } from './routes/dynamic.js';
@@ -14,6 +15,7 @@ export interface ServerContext {
14
15
  app: ReturnType<typeof Fastify>;
15
16
  sourceManager: SourceManager;
16
17
  endpoints: Map<string, EndpointConfig>;
18
+ watchManager: WatchManager;
17
19
  }
18
20
 
19
21
  export async function createServer(
@@ -95,7 +97,14 @@ export async function createServer(
95
97
  registerSSERoutes(app, sourceManager);
96
98
  registerDynamicRoutes(app, sourceManager, endpoints);
97
99
 
100
+ // Watches: register and start polling. Failures in any single watch are
101
+ // isolated — see WatchManager for the per-watch error handling.
102
+ const watchManager = new WatchManager(sourceManager);
103
+ if (config.watches && Object.keys(config.watches).length > 0) {
104
+ watchManager.start(config.watches);
105
+ }
106
+
98
107
  activity.info('Server ready');
99
108
 
100
- return { app, sourceManager, endpoints };
109
+ return { app, sourceManager, endpoints, watchManager };
101
110
  }