@aprovan/stitchery 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,365 @@
1
+ import { createServer, type Server } from 'node:http';
2
+ import { createMCPClient } from '@ai-sdk/mcp';
3
+ import { Experimental_StdioMCPTransport } from '@ai-sdk/mcp/mcp-stdio';
4
+ import { createUtcpBackend } from '@aprovan/patchwork-utcp';
5
+ import { jsonSchema, type Tool } from 'ai';
6
+ import type { ServerConfig, McpServerConfig } from '../types.js';
7
+ import { handleChat, handleEdit, type RouteContext } from './routes.js';
8
+ import { handleLocalPackages } from './local-packages.js';
9
+ import { handleVFS, type VFSContext } from './vfs-routes.js';
10
+ import { ServiceRegistry, generateServicesPrompt } from './services.js';
11
+
12
+ export interface StitcheryServer {
13
+ server: Server;
14
+ registry: ServiceRegistry;
15
+ start(): Promise<{ port: number; host: string }>;
16
+ stop(): Promise<void>;
17
+ }
18
+
19
+ async function initMcpTools(
20
+ configs: McpServerConfig[],
21
+ registry: ServiceRegistry,
22
+ ): Promise<void> {
23
+ for (const config of configs) {
24
+ const client = await createMCPClient({
25
+ transport: new Experimental_StdioMCPTransport({
26
+ command: config.command,
27
+ args: config.args,
28
+ }),
29
+ });
30
+ // Use MCP server name as namespace for all tools from this server
31
+ registry.registerTools(await client.tools(), config.name);
32
+ }
33
+ }
34
+
35
+ const searchServicesSchema = {
36
+ type: 'object',
37
+ properties: {
38
+ query: {
39
+ type: 'string',
40
+ description:
41
+ 'Natural language description of what you want to do (e.g., "get weather forecast", "list github repos")',
42
+ },
43
+ namespace: {
44
+ type: 'string',
45
+ description:
46
+ 'Filter results to a specific service namespace (e.g., "weather", "github")',
47
+ },
48
+ tool_name: {
49
+ type: 'string',
50
+ description: 'Get detailed info about a specific tool by name',
51
+ },
52
+ limit: {
53
+ type: 'number',
54
+ description: 'Maximum number of results to return',
55
+ default: 10,
56
+ },
57
+ include_interfaces: {
58
+ type: 'boolean',
59
+ description: 'Include TypeScript interface definitions in results',
60
+ default: true,
61
+ },
62
+ },
63
+ } as const;
64
+
65
+ interface SearchServicesArgs {
66
+ query?: string;
67
+ namespace?: string;
68
+ tool_name?: string;
69
+ limit?: number;
70
+ include_interfaces?: boolean;
71
+ }
72
+
73
+ /**
74
+ * Create the search_services tool for LLM use
75
+ */
76
+ function createSearchServicesTool(registry: ServiceRegistry): Tool {
77
+ return {
78
+ description: `Search for available services/tools. Use this to discover what APIs are available for widgets to call.
79
+
80
+ Returns matching services with their TypeScript interfaces. Use when:
81
+ - You need to find a service to accomplish a task
82
+ - You want to explore available APIs in a namespace
83
+ - You need the exact interface/parameters for a service call`,
84
+ inputSchema: jsonSchema<SearchServicesArgs>(searchServicesSchema),
85
+ execute: async (args: SearchServicesArgs) => {
86
+ // If requesting specific tool info
87
+ if (args.tool_name) {
88
+ const info = registry.getToolInfo(args.tool_name);
89
+ if (!info) {
90
+ return {
91
+ success: false,
92
+ error: `Tool '${args.tool_name}' not found`,
93
+ };
94
+ }
95
+ return { success: true, tool: info };
96
+ }
97
+
98
+ // Search for tools
99
+ const results = registry.searchServices({
100
+ query: args.query,
101
+ namespace: args.namespace,
102
+ limit: args.limit ?? 10,
103
+ includeInterfaces: args.include_interfaces ?? true,
104
+ });
105
+
106
+ return {
107
+ success: true,
108
+ count: results.length,
109
+ tools: results,
110
+ namespaces: registry.getNamespaces(),
111
+ };
112
+ },
113
+ };
114
+ }
115
+
116
+ function parseBody<T>(req: import('node:http').IncomingMessage): Promise<T> {
117
+ return new Promise((resolve, reject) => {
118
+ let body = '';
119
+ req.on('data', (chunk) => (body += chunk));
120
+ req.on('end', () => {
121
+ try {
122
+ resolve(JSON.parse(body));
123
+ } catch (err) {
124
+ reject(err);
125
+ }
126
+ });
127
+ req.on('error', reject);
128
+ });
129
+ }
130
+
131
+ export async function createStitcheryServer(
132
+ config: Partial<ServerConfig> = {},
133
+ ): Promise<StitcheryServer> {
134
+ const {
135
+ port = 6434,
136
+ host = '127.0.0.1',
137
+ copilotProxyUrl = 'http://127.0.0.1:6433/v1',
138
+ localPackages = {},
139
+ mcpServers = [],
140
+ utcp,
141
+ vfsDir,
142
+ vfsUsePaths = false,
143
+ verbose = false,
144
+ } = config;
145
+
146
+ const log = verbose
147
+ ? (...args: unknown[]) => console.log('[stitchery]', ...args)
148
+ : () => {};
149
+
150
+ // Create service registry
151
+ const registry = new ServiceRegistry();
152
+
153
+ log('Initializing MCP tools...');
154
+ await initMcpTools(mcpServers, registry);
155
+ log(`Loaded ${registry.size} tools from ${mcpServers.length} MCP servers`);
156
+
157
+ // Initialize UTCP backend if config provided
158
+ if (utcp) {
159
+ log('Initializing UTCP backend...');
160
+ log('UTCP config:', JSON.stringify(utcp, null, 2));
161
+ try {
162
+ // Cast to unknown since createUtcpBackend uses UtcpClientConfigSerializer to validate
163
+ const { backend, toolInfos } = await createUtcpBackend(
164
+ utcp as unknown as Parameters<typeof createUtcpBackend>[0],
165
+ utcp.cwd,
166
+ );
167
+ registry.registerBackend(backend, toolInfos);
168
+ log(
169
+ `Registered UTCP backend with ${toolInfos.length} tools:`,
170
+ toolInfos.map((t) => t.name).join(', '),
171
+ );
172
+ } catch (err) {
173
+ console.error('[stitchery] Failed to initialize UTCP backend:', err);
174
+ }
175
+ }
176
+
177
+ log('Local packages:', localPackages);
178
+
179
+ // Create internal tools (search_services, etc.)
180
+ const internalTools = {
181
+ search_services: createSearchServicesTool(registry),
182
+ };
183
+
184
+ // Combine MCP tools with internal tools
185
+ const allTools = { ...registry.getTools(), ...internalTools };
186
+
187
+ const routeCtx: RouteContext = {
188
+ copilotProxyUrl,
189
+ tools: allTools,
190
+ registry,
191
+ servicesPrompt: generateServicesPrompt(registry),
192
+ log,
193
+ };
194
+
195
+ const localPkgCtx = { localPackages, log };
196
+
197
+ const vfsCtx: VFSContext | null = vfsDir
198
+ ? { rootDir: vfsDir, usePaths: vfsUsePaths, log }
199
+ : null;
200
+
201
+ const server = createServer(async (req, res) => {
202
+ res.setHeader('Access-Control-Allow-Origin', '*');
203
+ res.setHeader(
204
+ 'Access-Control-Allow-Methods',
205
+ 'GET, POST, PUT, DELETE, HEAD, OPTIONS',
206
+ );
207
+ res.setHeader(
208
+ 'Access-Control-Allow-Headers',
209
+ 'Content-Type, Authorization',
210
+ );
211
+
212
+ if (req.method === 'OPTIONS') {
213
+ res.writeHead(204);
214
+ res.end();
215
+ return;
216
+ }
217
+
218
+ const url = req.url || '/';
219
+ log(`${req.method} ${url}`);
220
+
221
+ try {
222
+ if (handleLocalPackages(req, res, localPkgCtx)) {
223
+ return;
224
+ }
225
+
226
+ if (vfsCtx && handleVFS(req, res, vfsCtx)) {
227
+ return;
228
+ }
229
+
230
+ if (url === '/api/chat' && req.method === 'POST') {
231
+ await handleChat(req, res, routeCtx);
232
+ return;
233
+ }
234
+
235
+ if (url === '/api/edit' && req.method === 'POST') {
236
+ await handleEdit(req, res, routeCtx);
237
+ return;
238
+ }
239
+
240
+ // Service proxy endpoint for widgets
241
+ const proxyMatch = url.match(/^\/api\/proxy\/([^/]+)\/(.+)$/);
242
+ if (proxyMatch && req.method === 'POST') {
243
+ const [, namespace, procedure] = proxyMatch;
244
+ try {
245
+ const body = await parseBody<{ args?: unknown }>(req);
246
+ const result = await registry.call(
247
+ namespace!,
248
+ procedure!,
249
+ body.args ?? {},
250
+ );
251
+ res.setHeader('Content-Type', 'application/json');
252
+ res.writeHead(200);
253
+ res.end(JSON.stringify(result));
254
+ } catch (err) {
255
+ log('Proxy error:', err);
256
+ res.setHeader('Content-Type', 'application/json');
257
+ res.writeHead(500);
258
+ res.end(
259
+ JSON.stringify({
260
+ error: err instanceof Error ? err.message : 'Service call failed',
261
+ }),
262
+ );
263
+ }
264
+ return;
265
+ }
266
+
267
+ // Services search endpoint (POST with body for complex queries)
268
+ if (url === '/api/services/search' && req.method === 'POST') {
269
+ const body = await parseBody<{
270
+ query?: string;
271
+ namespace?: string;
272
+ tool_name?: string;
273
+ limit?: number;
274
+ include_interfaces?: boolean;
275
+ }>(req);
276
+
277
+ res.setHeader('Content-Type', 'application/json');
278
+ res.writeHead(200);
279
+
280
+ if (body.tool_name) {
281
+ const info = registry.getToolInfo(body.tool_name);
282
+ if (!info) {
283
+ res.end(
284
+ JSON.stringify({
285
+ success: false,
286
+ error: `Tool '${body.tool_name}' not found`,
287
+ }),
288
+ );
289
+ } else {
290
+ res.end(JSON.stringify({ success: true, tool: info }));
291
+ }
292
+ return;
293
+ }
294
+
295
+ const results = registry.searchServices({
296
+ query: body.query,
297
+ namespace: body.namespace,
298
+ limit: body.limit ?? 20,
299
+ includeInterfaces: body.include_interfaces ?? false,
300
+ });
301
+
302
+ res.end(
303
+ JSON.stringify({
304
+ success: true,
305
+ count: results.length,
306
+ tools: results,
307
+ namespaces: registry.getNamespaces(),
308
+ }),
309
+ );
310
+ return;
311
+ }
312
+
313
+ // Services metadata endpoint
314
+ if (url === '/api/services' && req.method === 'GET') {
315
+ res.setHeader('Content-Type', 'application/json');
316
+ res.writeHead(200);
317
+ res.end(
318
+ JSON.stringify({
319
+ namespaces: registry.getNamespaces(),
320
+ services: registry.getServiceInfo(),
321
+ }),
322
+ );
323
+ return;
324
+ }
325
+
326
+ if (url === '/health' || url === '/') {
327
+ res.setHeader('Content-Type', 'application/json');
328
+ res.writeHead(200);
329
+ res.end(JSON.stringify({ status: 'ok', service: 'stitchery' }));
330
+ return;
331
+ }
332
+
333
+ res.writeHead(404);
334
+ res.end(`Not found: ${url}`);
335
+ } catch (err) {
336
+ log('Error:', err);
337
+ res.writeHead(500);
338
+ res.end(err instanceof Error ? err.message : 'Internal server error');
339
+ }
340
+ });
341
+
342
+ return {
343
+ server,
344
+ registry,
345
+
346
+ async start() {
347
+ return new Promise((resolve, reject) => {
348
+ server.on('error', reject);
349
+ server.listen(port, host, () => {
350
+ log(`Server listening on http://${host}:${port}`);
351
+ resolve({ port, host });
352
+ });
353
+ });
354
+ },
355
+
356
+ async stop() {
357
+ return new Promise((resolve, reject) => {
358
+ server.close((err) => {
359
+ if (err) reject(err);
360
+ else resolve();
361
+ });
362
+ });
363
+ },
364
+ };
365
+ }
@@ -0,0 +1,91 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+
5
+ export interface LocalPackagesContext {
6
+ localPackages: Record<string, string>;
7
+ log: (...args: unknown[]) => void;
8
+ }
9
+
10
+ export function handleLocalPackages(
11
+ req: IncomingMessage,
12
+ res: ServerResponse,
13
+ ctx: LocalPackagesContext,
14
+ ): boolean {
15
+ const rawUrl = req.url || '';
16
+
17
+ // Only handle /_local-packages routes
18
+ if (!rawUrl.startsWith('/_local-packages')) {
19
+ return false;
20
+ }
21
+
22
+ const urlWithoutPrefix = rawUrl.replace('/_local-packages', '');
23
+
24
+ // Strip query string (bundlers add ?import to dynamic imports)
25
+ const url = urlWithoutPrefix.split('?')[0] || '';
26
+
27
+ // Parse the package name from URL (handles scoped packages like @scope/name)
28
+ const match = url.match(/^\/@([^/]+)\/([^/@]+)(.*)$/);
29
+ if (!match) {
30
+ return false;
31
+ }
32
+
33
+ const [, scope, name, restPath] = match;
34
+ const packageName = `@${scope}/${name}`;
35
+ const localPath = ctx.localPackages[packageName];
36
+
37
+ if (!localPath) {
38
+ res.writeHead(404);
39
+ res.end(`Package ${packageName} not found in local overrides`);
40
+ return true;
41
+ }
42
+
43
+ // Determine what file to serve
44
+ const rest = restPath || '';
45
+ let filePath: string;
46
+
47
+ try {
48
+ if (rest === '/package.json') {
49
+ filePath = path.join(localPath, 'package.json');
50
+ } else if (rest === '' || rest === '/') {
51
+ const pkgJson = JSON.parse(
52
+ fs.readFileSync(path.join(localPath, 'package.json'), 'utf-8'),
53
+ );
54
+ const mainEntry = pkgJson.main || 'dist/index.js';
55
+ filePath = path.join(localPath, mainEntry);
56
+ } else {
57
+ const normalizedPath = rest.startsWith('/') ? rest.slice(1) : rest;
58
+ const distPath = path.join(localPath, 'dist', normalizedPath);
59
+ const rootPath = path.join(localPath, normalizedPath);
60
+ filePath = fs.existsSync(distPath) ? distPath : rootPath;
61
+ }
62
+ } catch (err) {
63
+ ctx.log('Error resolving file path:', err);
64
+ res.writeHead(500);
65
+ res.end(`Error resolving path for ${packageName}: ${err}`);
66
+ return true;
67
+ }
68
+
69
+ try {
70
+ ctx.log(`Serving ${filePath}`);
71
+ const content = fs.readFileSync(filePath, 'utf-8');
72
+ const ext = path.extname(filePath);
73
+ const contentType =
74
+ ext === '.json'
75
+ ? 'application/json'
76
+ : ext === '.js'
77
+ ? 'application/javascript'
78
+ : ext === '.ts'
79
+ ? 'application/typescript'
80
+ : 'text/plain';
81
+ res.setHeader('Content-Type', contentType);
82
+ res.writeHead(200);
83
+ res.end(content);
84
+ } catch (err) {
85
+ ctx.log('Error serving file:', filePath, err);
86
+ res.writeHead(404);
87
+ res.end(`File not found: ${filePath}`);
88
+ }
89
+
90
+ return true;
91
+ }
@@ -0,0 +1,122 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
3
+ import {
4
+ streamText,
5
+ convertToModelMessages,
6
+ stepCountIs,
7
+ type UIMessage,
8
+ type Tool,
9
+ } from 'ai';
10
+ import { PATCHWORK_PROMPT, EDIT_PROMPT } from '../prompts.js';
11
+ import type { ServiceRegistry } from './services.js';
12
+
13
+ export interface RouteContext {
14
+ copilotProxyUrl: string;
15
+ tools: Record<string, Tool>;
16
+ registry: ServiceRegistry;
17
+ servicesPrompt: string;
18
+ log: (...args: unknown[]) => void;
19
+ }
20
+
21
+ function parseBody<T>(req: IncomingMessage): Promise<T> {
22
+ return new Promise((resolve, reject) => {
23
+ let body = '';
24
+ req.on('data', (chunk) => (body += chunk));
25
+ req.on('end', () => {
26
+ try {
27
+ resolve(JSON.parse(body));
28
+ } catch (err) {
29
+ reject(err);
30
+ }
31
+ });
32
+ req.on('error', reject);
33
+ });
34
+ }
35
+
36
+ export async function handleChat(
37
+ req: IncomingMessage,
38
+ res: ServerResponse,
39
+ ctx: RouteContext,
40
+ ): Promise<void> {
41
+ const {
42
+ messages,
43
+ metadata,
44
+ }: {
45
+ messages: UIMessage[];
46
+ metadata?: { patchwork?: { compilers?: string[] } };
47
+ } = await parseBody(req);
48
+
49
+ const normalizedMessages = messages.map((msg) => ({
50
+ ...msg,
51
+ parts: msg.parts ?? [{ type: 'text' as const, text: '' }],
52
+ }));
53
+
54
+ const provider = createOpenAICompatible({
55
+ name: 'copilot-proxy',
56
+ baseURL: ctx.copilotProxyUrl,
57
+ });
58
+
59
+ const result = streamText({
60
+ model: provider('claude-sonnet-4'),
61
+ system: `---\npatchwork:\n compilers: ${
62
+ (metadata?.patchwork?.compilers ?? []).join(',') ?? '[]'
63
+ }\n services: ${ctx.registry
64
+ .getNamespaces()
65
+ .join(',')}\n---\n\n${PATCHWORK_PROMPT}\n\n${ctx.servicesPrompt}`,
66
+ messages: await convertToModelMessages(normalizedMessages),
67
+ stopWhen: stepCountIs(5),
68
+ tools: ctx.tools,
69
+ });
70
+
71
+ const response = result.toUIMessageStreamResponse();
72
+ response.headers.forEach((value: string, key: string) =>
73
+ res.setHeader(key, value),
74
+ );
75
+
76
+ if (!response.body) {
77
+ res.end();
78
+ return;
79
+ }
80
+
81
+ const reader = response.body.getReader();
82
+ const pump = async () => {
83
+ const { done, value } = await reader.read();
84
+ if (done) {
85
+ res.end();
86
+ return;
87
+ }
88
+ res.write(value);
89
+ await pump();
90
+ };
91
+ await pump();
92
+ }
93
+
94
+ export async function handleEdit(
95
+ req: IncomingMessage,
96
+ res: ServerResponse,
97
+ ctx: RouteContext,
98
+ ): Promise<void> {
99
+ const { code, prompt }: { code: string; prompt: string } = await parseBody(
100
+ req,
101
+ );
102
+
103
+ const provider = createOpenAICompatible({
104
+ name: 'copilot-proxy',
105
+ baseURL: ctx.copilotProxyUrl,
106
+ });
107
+
108
+ const result = streamText({
109
+ model: provider('claude-opus-4.5'),
110
+ system: `Current component code:\n\`\`\`tsx\n${code}\n\`\`\`\n\n${EDIT_PROMPT}`,
111
+ messages: [{ role: 'user', content: prompt }],
112
+ });
113
+
114
+ res.setHeader('Content-Type', 'text/plain');
115
+ res.setHeader('Transfer-Encoding', 'chunked');
116
+ res.writeHead(200);
117
+
118
+ for await (const chunk of result.textStream) {
119
+ res.write(chunk);
120
+ }
121
+ res.end();
122
+ }