@aprovan/mcp-app-server 0.1.0-dev.4d82df8
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/.turbo/turbo-build.log +23 -0
- package/E2E_TESTING.md +224 -0
- package/LICENSE +373 -0
- package/README.md +164 -0
- package/dist/index.d.ts +128 -0
- package/dist/index.js +990 -0
- package/dist/index.js.map +1 -0
- package/dist/runtime/assets/__vite-browser-external-BIHI7g3E.js +1 -0
- package/dist/runtime/assets/index-tRNuU9ap.js +1059 -0
- package/dist/runtime/index.html +38 -0
- package/dist/server.d.ts +6 -0
- package/dist/server.js +1511 -0
- package/dist/server.js.map +1 -0
- package/dist/shell/shell.js +67 -0
- package/docs/widget-preview.png +0 -0
- package/e2e/__snapshots__/.gitkeep +0 -0
- package/e2e/global-setup.ts +114 -0
- package/e2e/global-teardown.ts +15 -0
- package/e2e/visual-regression.test.ts +86 -0
- package/e2e/widget-smoke.test.ts +109 -0
- package/index.html +32 -0
- package/package.json +51 -0
- package/playwright.config.ts +43 -0
- package/src/__tests__/live-update.test.ts +158 -0
- package/src/__tests__/local-backend.test.ts +100 -0
- package/src/__tests__/memory-backend.ts +144 -0
- package/src/__tests__/registry-backend.test.ts +256 -0
- package/src/__tests__/services.test.ts +153 -0
- package/src/__tests__/shim.test.ts +60 -0
- package/src/__tests__/widget-store.test.ts +188 -0
- package/src/e2e-visual.ts +148 -0
- package/src/index.ts +608 -0
- package/src/live-update.ts +150 -0
- package/src/logger.ts +44 -0
- package/src/reference-widgets/live-dashboard.ts +162 -0
- package/src/registry-backend.ts +306 -0
- package/src/runtime/index.html +38 -0
- package/src/runtime/main.ts +164 -0
- package/src/scripts/export-artifacts.ts +51 -0
- package/src/server.ts +398 -0
- package/src/services.ts +307 -0
- package/src/shell/main.ts +172 -0
- package/src/shim.ts +106 -0
- package/src/tunnel.ts +123 -0
- package/src/widget-store/index.ts +10 -0
- package/src/widget-store/local-backend.ts +77 -0
- package/src/widget-store/store.ts +242 -0
- package/src/widget-store/types.ts +53 -0
- package/tsconfig.json +10 -0
- package/tsup.config.ts +14 -0
- package/vite.runtime.config.ts +28 -0
- package/vite.shell.config.ts +30 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { warn } from "./logger.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A single buffered event for a data stream.
|
|
6
|
+
*/
|
|
7
|
+
export interface StreamEvent {
|
|
8
|
+
seq: number;
|
|
9
|
+
data: unknown;
|
|
10
|
+
timestamp: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const RING_BUFFER_MAX = 100;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Process-level ring buffer for each named stream.
|
|
17
|
+
* Shared across all sessions so any request can read from it.
|
|
18
|
+
*/
|
|
19
|
+
const streamBuffers = new Map<string, StreamEvent[]>();
|
|
20
|
+
let globalSeq = 0;
|
|
21
|
+
|
|
22
|
+
function getOrCreateBuffer(stream: string): StreamEvent[] {
|
|
23
|
+
let buf = streamBuffers.get(stream);
|
|
24
|
+
if (!buf) {
|
|
25
|
+
buf = [];
|
|
26
|
+
streamBuffers.set(stream, buf);
|
|
27
|
+
}
|
|
28
|
+
return buf;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Retrieve buffered events for a stream with sequence numbers > afterSeq.
|
|
33
|
+
*/
|
|
34
|
+
export function getEvents(stream: string, afterSeq: number): StreamEvent[] {
|
|
35
|
+
const buf = streamBuffers.get(stream);
|
|
36
|
+
if (!buf) return [];
|
|
37
|
+
return buf.filter((e) => e.seq > afterSeq);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Write an event into the ring buffer for a stream.
|
|
42
|
+
* Evicts the oldest entry when the buffer is full.
|
|
43
|
+
*/
|
|
44
|
+
export function appendEvent(stream: string, data: unknown): StreamEvent {
|
|
45
|
+
const buf = getOrCreateBuffer(stream);
|
|
46
|
+
const event: StreamEvent = {
|
|
47
|
+
seq: ++globalSeq,
|
|
48
|
+
data,
|
|
49
|
+
timestamp: Date.now(),
|
|
50
|
+
};
|
|
51
|
+
buf.push(event);
|
|
52
|
+
if (buf.length > RING_BUFFER_MAX) {
|
|
53
|
+
buf.shift();
|
|
54
|
+
}
|
|
55
|
+
return event;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Per-session subscription tracking.
|
|
60
|
+
* A session is keyed by the MCP session ID; its McpServer is used to send
|
|
61
|
+
* `notifications/tools/list_changed` as a push signal when stream events arrive.
|
|
62
|
+
*/
|
|
63
|
+
interface SessionEntry {
|
|
64
|
+
server: McpServer;
|
|
65
|
+
streams: Set<string>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const sessions = new Map<string, SessionEntry>();
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Register a live MCP session so it can receive push notifications.
|
|
72
|
+
*/
|
|
73
|
+
export function registerSession(sessionId: string, server: McpServer): void {
|
|
74
|
+
if (!sessions.has(sessionId)) {
|
|
75
|
+
sessions.set(sessionId, { server, streams: new Set() });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Unregister a session (e.g. on transport close or DELETE).
|
|
81
|
+
*/
|
|
82
|
+
export function unregisterSession(sessionId: string): void {
|
|
83
|
+
sessions.delete(sessionId);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Subscribe a session to a named stream.
|
|
88
|
+
*/
|
|
89
|
+
export function subscribeSession(sessionId: string, stream: string): void {
|
|
90
|
+
const entry = sessions.get(sessionId);
|
|
91
|
+
if (entry) {
|
|
92
|
+
entry.streams.add(stream);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Unsubscribe a session from a named stream.
|
|
98
|
+
*/
|
|
99
|
+
export function unsubscribeSession(sessionId: string, stream: string): void {
|
|
100
|
+
const entry = sessions.get(sessionId);
|
|
101
|
+
if (entry) {
|
|
102
|
+
entry.streams.delete(stream);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Push a data update to all sessions that have subscribed to `stream`.
|
|
108
|
+
*
|
|
109
|
+
* The event is appended to the ring buffer. Each subscribed session's McpServer
|
|
110
|
+
* then sends a `notifications/tools/list_changed` notification to the host.
|
|
111
|
+
* The host forwards this to the widget, which uses it as a signal to call
|
|
112
|
+
* `poll_updates` and retrieve the buffered data.
|
|
113
|
+
*
|
|
114
|
+
* @returns The new sequence number assigned to this event.
|
|
115
|
+
*/
|
|
116
|
+
export async function pushStreamUpdate(
|
|
117
|
+
stream: string,
|
|
118
|
+
data: unknown,
|
|
119
|
+
): Promise<number> {
|
|
120
|
+
const event = appendEvent(stream, data);
|
|
121
|
+
|
|
122
|
+
const pushPromises: Promise<void>[] = [];
|
|
123
|
+
for (const [, entry] of sessions) {
|
|
124
|
+
if (entry.streams.has(stream)) {
|
|
125
|
+
const notifyPromise = entry.server.server
|
|
126
|
+
.notification({ method: "notifications/tools/list_changed" })
|
|
127
|
+
.catch((err: unknown) => {
|
|
128
|
+
warn("live-update", "Failed to notify session:", err);
|
|
129
|
+
});
|
|
130
|
+
pushPromises.push(notifyPromise);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
await Promise.all(pushPromises);
|
|
135
|
+
return event.seq;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Return the current highest sequence number (useful for initial subscribe).
|
|
140
|
+
*/
|
|
141
|
+
export function currentSeq(): number {
|
|
142
|
+
return globalSeq;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Clears all buffers and sessions — for testing only. */
|
|
146
|
+
export function _resetForTests(): void {
|
|
147
|
+
streamBuffers.clear();
|
|
148
|
+
sessions.clear();
|
|
149
|
+
globalSeq = 0;
|
|
150
|
+
}
|
package/src/logger.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
|
2
|
+
|
|
3
|
+
const CURRENT_LEVEL: LogLevel =
|
|
4
|
+
(process.env['LOG_LEVEL'] as LogLevel | undefined) ?? 'info';
|
|
5
|
+
|
|
6
|
+
const LEVEL_RANK: Record<LogLevel, number> = {
|
|
7
|
+
debug: 0,
|
|
8
|
+
info: 1,
|
|
9
|
+
warn: 2,
|
|
10
|
+
error: 3,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
function shouldLog(level: LogLevel): boolean {
|
|
14
|
+
return LEVEL_RANK[level] >= LEVEL_RANK[CURRENT_LEVEL];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function formatMessage(tag: string, ...args: unknown[]): unknown[] {
|
|
18
|
+
return [`[${tag}]`, ...args];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// All logging goes to stderr to avoid interfering with MCP stdio protocol on stdout
|
|
22
|
+
export function debug(tag: string, ...args: unknown[]): void {
|
|
23
|
+
if (shouldLog('debug')) {
|
|
24
|
+
console.error(...formatMessage(tag, ...args));
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function log(tag: string, ...args: unknown[]): void {
|
|
29
|
+
if (shouldLog('info')) {
|
|
30
|
+
console.error(...formatMessage(tag, ...args));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function warn(tag: string, ...args: unknown[]): void {
|
|
35
|
+
if (shouldLog('warn')) {
|
|
36
|
+
console.error(...formatMessage(tag, ...args));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function error(tag: string, ...args: unknown[]): void {
|
|
41
|
+
if (shouldLog('error')) {
|
|
42
|
+
console.error(...formatMessage(tag, ...args));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import type { VirtualFile } from "@aprovan/patchwork-compiler";
|
|
2
|
+
|
|
3
|
+
const DASHBOARD_MAIN = `import { useState, useEffect } from 'react';
|
|
4
|
+
import { PriceCard } from './price-card';
|
|
5
|
+
import { StatusPanel } from './status-panel';
|
|
6
|
+
import { ActionBar } from './action-bar';
|
|
7
|
+
|
|
8
|
+
export default function LiveDashboard() {
|
|
9
|
+
const [prices, setPrices] = useState<Record<string, { price: number; change: number }>>({});
|
|
10
|
+
const [statuses, setStatuses] = useState<Record<string, string>>({});
|
|
11
|
+
const [lastUpdate, setLastUpdate] = useState<number>(Date.now());
|
|
12
|
+
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
const unsub1 = window.patchwork.subscribe('price_feed', (data: any, seq: number) => {
|
|
15
|
+
setPrices(prev => ({ ...prev, ...data }));
|
|
16
|
+
setLastUpdate(Date.now());
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const unsub2 = window.patchwork.subscribe('system_status', (data: any, seq: number) => {
|
|
20
|
+
setStatuses(prev => ({ ...prev, ...data }));
|
|
21
|
+
setLastUpdate(Date.now());
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
return () => { unsub1(); unsub2(); };
|
|
25
|
+
}, []);
|
|
26
|
+
|
|
27
|
+
const handleRefresh = () => {
|
|
28
|
+
window.patchwork.fireEvent('push_update', {
|
|
29
|
+
stream: 'price_feed',
|
|
30
|
+
data: { tick: true },
|
|
31
|
+
});
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const handleContextUpdate = () => {
|
|
35
|
+
const summary = Object.entries(prices)
|
|
36
|
+
.map(([sym, p]) => \`\${sym}: $\${p.price.toFixed(2)} (\${p.change >= 0 ? '+' : ''}\${p.change.toFixed(2)}%)\`)
|
|
37
|
+
.join('; ');
|
|
38
|
+
window.patchwork.updateContext(\`Dashboard state: \${summary}\`);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const priceEntries = Object.entries(prices);
|
|
42
|
+
const statusEntries = Object.entries(statuses);
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<div className="p-6 max-w-2xl mx-auto bg-white rounded-xl shadow-lg space-y-6">
|
|
46
|
+
<div className="flex items-center justify-between border-b pb-3">
|
|
47
|
+
<h1 className="text-xl font-bold text-gray-900">Live Data Dashboard</h1>
|
|
48
|
+
<span className="text-xs text-gray-400">
|
|
49
|
+
Updated {new Date(lastUpdate).toLocaleTimeString()}
|
|
50
|
+
</span>
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
{priceEntries.length > 0 && (
|
|
54
|
+
<section>
|
|
55
|
+
<h2 className="text-sm font-semibold text-gray-600 uppercase tracking-wide mb-2">
|
|
56
|
+
Price Feed
|
|
57
|
+
</h2>
|
|
58
|
+
<div className="grid grid-cols-2 gap-3">
|
|
59
|
+
{priceEntries.map(([symbol, p]) => (
|
|
60
|
+
<PriceCard key={symbol} symbol={symbol} price={p.price} change={p.change} />
|
|
61
|
+
))}
|
|
62
|
+
</div>
|
|
63
|
+
</section>
|
|
64
|
+
)}
|
|
65
|
+
|
|
66
|
+
{statusEntries.length > 0 && (
|
|
67
|
+
<section>
|
|
68
|
+
<h2 className="text-sm font-semibold text-gray-600 uppercase tracking-wide mb-2">
|
|
69
|
+
System Status
|
|
70
|
+
</h2>
|
|
71
|
+
<StatusPanel statuses={statuses} />
|
|
72
|
+
</section>
|
|
73
|
+
)}
|
|
74
|
+
|
|
75
|
+
<ActionBar onRefresh={handleRefresh} onContextUpdate={handleContextUpdate} />
|
|
76
|
+
|
|
77
|
+
{priceEntries.length === 0 && statusEntries.length === 0 && (
|
|
78
|
+
<div className="text-center py-8 text-gray-400">
|
|
79
|
+
<p>Waiting for live data...</p>
|
|
80
|
+
<p className="text-xs mt-1">Use push_update or subscribe to streams to see data here.</p>
|
|
81
|
+
</div>
|
|
82
|
+
)}
|
|
83
|
+
</div>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
`;
|
|
87
|
+
|
|
88
|
+
const PRICE_CARD = `export function PriceCard({ symbol, price, change }: { symbol: string; price: number; change: number }) {
|
|
89
|
+
const isPositive = change >= 0;
|
|
90
|
+
return (
|
|
91
|
+
<div className="p-3 rounded-lg border bg-gray-50">
|
|
92
|
+
<div className="text-xs font-medium text-gray-500 uppercase">{symbol}</div>
|
|
93
|
+
<div className="text-lg font-bold text-gray-900">\${price.toFixed(2)}</div>
|
|
94
|
+
<div className={\`text-sm font-medium \${isPositive ? 'text-green-600' : 'text-red-600'}\`}>
|
|
95
|
+
{isPositive ? '+' : ''}{change.toFixed(2)}%
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
`;
|
|
101
|
+
|
|
102
|
+
const STATUS_PANEL = `export function StatusPanel({ statuses }: { statuses: Record<string, string> }) {
|
|
103
|
+
const colorMap: Record<string, string> = {
|
|
104
|
+
ok: 'bg-green-100 text-green-800',
|
|
105
|
+
healthy: 'bg-green-100 text-green-800',
|
|
106
|
+
warning: 'bg-yellow-100 text-yellow-800',
|
|
107
|
+
degraded: 'bg-yellow-100 text-yellow-800',
|
|
108
|
+
error: 'bg-red-100 text-red-800',
|
|
109
|
+
down: 'bg-red-100 text-red-800',
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<div className="space-y-2">
|
|
114
|
+
{Object.entries(statuses).map(([service, status]) => {
|
|
115
|
+
const color = colorMap[status.toLowerCase()] ?? 'bg-gray-100 text-gray-800';
|
|
116
|
+
return (
|
|
117
|
+
<div key={service} className="flex items-center justify-between p-2 rounded border bg-gray-50">
|
|
118
|
+
<span className="text-sm font-medium text-gray-700">{service}</span>
|
|
119
|
+
<span className={\`text-xs font-semibold px-2 py-0.5 rounded \${color}\`}>{status}</span>
|
|
120
|
+
</div>
|
|
121
|
+
);
|
|
122
|
+
})}
|
|
123
|
+
</div>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
`;
|
|
127
|
+
|
|
128
|
+
const ACTION_BAR = `export function ActionBar({ onRefresh, onContextUpdate }: { onRefresh: () => void; onContextUpdate: () => void }) {
|
|
129
|
+
return (
|
|
130
|
+
<div className="flex gap-3 pt-2">
|
|
131
|
+
<button
|
|
132
|
+
onClick={onRefresh}
|
|
133
|
+
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors"
|
|
134
|
+
>
|
|
135
|
+
Refresh Data
|
|
136
|
+
</button>
|
|
137
|
+
<button
|
|
138
|
+
onClick={onContextUpdate}
|
|
139
|
+
className="px-4 py-2 text-sm font-medium text-blue-600 bg-blue-50 rounded-lg hover:bg-blue-100 transition-colors"
|
|
140
|
+
>
|
|
141
|
+
Send Context
|
|
142
|
+
</button>
|
|
143
|
+
</div>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
`;
|
|
147
|
+
|
|
148
|
+
export const REFERENCE_WIDGET_FILES: VirtualFile[] = [
|
|
149
|
+
{ path: "main.tsx", content: DASHBOARD_MAIN },
|
|
150
|
+
{ path: "price-card.tsx", content: PRICE_CARD },
|
|
151
|
+
{ path: "status-panel.tsx", content: STATUS_PANEL },
|
|
152
|
+
{ path: "action-bar.tsx", content: ACTION_BAR },
|
|
153
|
+
];
|
|
154
|
+
|
|
155
|
+
export const REFERENCE_WIDGET_MANIFEST = {
|
|
156
|
+
name: "live-dashboard",
|
|
157
|
+
version: "0.1.0",
|
|
158
|
+
platform: "browser" as const,
|
|
159
|
+
image: "@aprovan/patchwork-image-shadcn",
|
|
160
|
+
description: "Live data dashboard with price feed, system status, and context feedback",
|
|
161
|
+
services: ["weather"],
|
|
162
|
+
};
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registry MCP backend for the Patchwork MCP App Server.
|
|
3
|
+
*
|
|
4
|
+
* Spawns (or connects to) the Aprovan Registry MCP server via stdio and bridges
|
|
5
|
+
* its tools into the Patchwork ServiceBridge. Widgets can then call any Registry-
|
|
6
|
+
* backed service (e.g. `github.repos_list()`, `stripe.charges_list()`) via the
|
|
7
|
+
* standard Patchwork service proxy shim.
|
|
8
|
+
*
|
|
9
|
+
* Usage (in server.ts or programmatically):
|
|
10
|
+
*
|
|
11
|
+
* const backend = await createRegistryBackend({
|
|
12
|
+
* command: "npx",
|
|
13
|
+
* args: ["@utdk/mcp"],
|
|
14
|
+
* providers: "github,slack,stripe",
|
|
15
|
+
* });
|
|
16
|
+
*
|
|
17
|
+
* const bridge = new ServiceBridge({
|
|
18
|
+
* backend,
|
|
19
|
+
* tools: backend.getToolInfos(),
|
|
20
|
+
* });
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
24
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
25
|
+
import { log, warn } from "./logger.js";
|
|
26
|
+
import type { ServiceBackend, ServiceToolInfo } from "./services.js";
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// Minimal type alias for MCP callTool responses
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
/** Subset of the MCP CallToolResult used in this module. */
|
|
33
|
+
interface McpToolResult {
|
|
34
|
+
isError?: boolean;
|
|
35
|
+
content: Array<{ type: string; text?: string; [k: string]: unknown }>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Public types
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
export interface RegistryBackendOptions {
|
|
43
|
+
/** Executable to start the Registry MCP server (e.g. "npx" or an absolute path). */
|
|
44
|
+
command: string;
|
|
45
|
+
/** Arguments passed to the command (e.g. ["@utdk/mcp"]). */
|
|
46
|
+
args?: string[];
|
|
47
|
+
/**
|
|
48
|
+
* Additional environment variables for the Registry process.
|
|
49
|
+
* Merged on top of the current process.env, so provider credentials already
|
|
50
|
+
* present in the environment are forwarded automatically.
|
|
51
|
+
*/
|
|
52
|
+
env?: Record<string, string>;
|
|
53
|
+
/**
|
|
54
|
+
* Comma-separated list of `@utdk` providers to load, e.g. "github,slack,stripe".
|
|
55
|
+
* Passed as UTDK_PROVIDERS to the Registry server.
|
|
56
|
+
*/
|
|
57
|
+
providers: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface RegistryBackend extends ServiceBackend {
|
|
61
|
+
/** All service tool infos loaded from the Registry, ready for ServiceBridgeConfig. */
|
|
62
|
+
getToolInfos(): ServiceToolInfo[];
|
|
63
|
+
/** Close the underlying MCP client / Registry child process. */
|
|
64
|
+
close(): Promise<void>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// Internal helpers
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Split a Registry MCP tool name (e.g. "github__repos_list") into its
|
|
73
|
+
* namespace ("github") and procedure ("repos_list") parts.
|
|
74
|
+
*
|
|
75
|
+
* The Registry convention is:
|
|
76
|
+
* <provider>__<operation> (double underscore as separator)
|
|
77
|
+
*
|
|
78
|
+
* Everything before the first "__" is the namespace; the remainder is the
|
|
79
|
+
* procedure (which may itself contain single underscores).
|
|
80
|
+
*/
|
|
81
|
+
export function parseRegistryToolName(mcpName: string): {
|
|
82
|
+
namespace: string;
|
|
83
|
+
procedure: string;
|
|
84
|
+
} {
|
|
85
|
+
const idx = mcpName.indexOf("__");
|
|
86
|
+
if (idx === -1) {
|
|
87
|
+
// No separator — treat the whole name as a single-part namespace
|
|
88
|
+
return { namespace: mcpName, procedure: "call" };
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
namespace: mcpName.slice(0, idx),
|
|
92
|
+
procedure: mcpName.slice(idx + 2),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Fetch all tool metadata from the Registry via its `list_tools` / `tool_info`
|
|
98
|
+
* meta-tools and convert to the `ServiceToolInfo` shape expected by ServiceBridge.
|
|
99
|
+
*/
|
|
100
|
+
async function loadRegistryToolInfos(client: Client): Promise<ServiceToolInfo[]> {
|
|
101
|
+
// list_tools returns a flat JSON array of MCP tool names when no group_by is given.
|
|
102
|
+
const listResult = (await client.callTool({
|
|
103
|
+
name: "list_tools",
|
|
104
|
+
arguments: {},
|
|
105
|
+
})) as McpToolResult;
|
|
106
|
+
|
|
107
|
+
const listText = listResult.content
|
|
108
|
+
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
109
|
+
.map((c) => c.text)
|
|
110
|
+
.join("");
|
|
111
|
+
|
|
112
|
+
let toolNames: unknown;
|
|
113
|
+
try {
|
|
114
|
+
toolNames = JSON.parse(listText);
|
|
115
|
+
} catch {
|
|
116
|
+
warn("registry-backend", "Failed to parse tool list response from Registry");
|
|
117
|
+
return [];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (!Array.isArray(toolNames)) {
|
|
121
|
+
warn("registry-backend", "Unexpected tool list format from Registry (expected array)");
|
|
122
|
+
return [];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const mcpNames = toolNames.filter((n): n is string => typeof n === "string");
|
|
126
|
+
|
|
127
|
+
// Fetch full schema for each tool in parallel, chunked to avoid flooding the process.
|
|
128
|
+
const CHUNK_SIZE = 20;
|
|
129
|
+
const toolInfos: ServiceToolInfo[] = [];
|
|
130
|
+
|
|
131
|
+
for (let i = 0; i < mcpNames.length; i += CHUNK_SIZE) {
|
|
132
|
+
const chunk = mcpNames.slice(i, i + CHUNK_SIZE);
|
|
133
|
+
const results = await Promise.all(
|
|
134
|
+
chunk.map(async (mcpName): Promise<ServiceToolInfo | null> => {
|
|
135
|
+
try {
|
|
136
|
+
const infoResult = (await client.callTool({
|
|
137
|
+
name: "tool_info",
|
|
138
|
+
arguments: { tool_name: mcpName },
|
|
139
|
+
})) as McpToolResult;
|
|
140
|
+
|
|
141
|
+
const infoText = infoResult.content
|
|
142
|
+
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
143
|
+
.map((c) => c.text)
|
|
144
|
+
.join("");
|
|
145
|
+
|
|
146
|
+
const raw = JSON.parse(infoText) as {
|
|
147
|
+
name?: string;
|
|
148
|
+
description?: string;
|
|
149
|
+
inputSchema?: Record<string, unknown>;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const { namespace, procedure } = parseRegistryToolName(mcpName);
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
name: `${namespace}.${procedure}`,
|
|
156
|
+
namespace,
|
|
157
|
+
procedure,
|
|
158
|
+
description: raw.description ?? `Call ${namespace}.${procedure}`,
|
|
159
|
+
parameters: raw.inputSchema as Record<string, unknown> | undefined,
|
|
160
|
+
};
|
|
161
|
+
} catch (err) {
|
|
162
|
+
warn("registry-backend", `Failed to fetch tool info for '${mcpName}': ${err}`);
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
}),
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
for (const info of results) {
|
|
169
|
+
if (info !== null) {
|
|
170
|
+
toolInfos.push(info);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const providerCount = new Set(toolInfos.map((t) => t.namespace)).size;
|
|
176
|
+
log(
|
|
177
|
+
"registry-backend",
|
|
178
|
+
`Loaded ${toolInfos.length} tools from ${providerCount} provider(s)`,
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
return toolInfos;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
// Public factory
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Create a `RegistryBackend` that routes widget service calls through the
|
|
190
|
+
* Aprovan Registry MCP server.
|
|
191
|
+
*
|
|
192
|
+
* The Registry server is spawned as a child process via stdio. All current
|
|
193
|
+
* `process.env` variables (including provider credentials such as `GITHUB_TOKEN`)
|
|
194
|
+
* are forwarded to the child process, with `UTDK_PROVIDERS` set to the given
|
|
195
|
+
* providers list. Additional overrides can be passed via `options.env`.
|
|
196
|
+
*
|
|
197
|
+
* @example
|
|
198
|
+
* ```ts
|
|
199
|
+
* const backend = await createRegistryBackend({
|
|
200
|
+
* command: "npx",
|
|
201
|
+
* args: ["@utdk/mcp"],
|
|
202
|
+
* providers: "github,stripe",
|
|
203
|
+
* });
|
|
204
|
+
*
|
|
205
|
+
* const bridge = new ServiceBridge({
|
|
206
|
+
* backend,
|
|
207
|
+
* tools: backend.getToolInfos(),
|
|
208
|
+
* });
|
|
209
|
+
* ```
|
|
210
|
+
*/
|
|
211
|
+
export async function createRegistryBackend(
|
|
212
|
+
options: RegistryBackendOptions,
|
|
213
|
+
): Promise<RegistryBackend> {
|
|
214
|
+
// Forward the current environment so provider API keys are available in the
|
|
215
|
+
// spawned process. UTDK_PROVIDERS is the only variable that must be set here.
|
|
216
|
+
const inheritedEnv: Record<string, string> = {};
|
|
217
|
+
for (const [k, v] of Object.entries(process.env)) {
|
|
218
|
+
if (v !== undefined) inheritedEnv[k] = v;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const env: Record<string, string> = {
|
|
222
|
+
...inheritedEnv,
|
|
223
|
+
UTDK_PROVIDERS: options.providers,
|
|
224
|
+
...options.env,
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const transport = new StdioClientTransport({
|
|
228
|
+
command: options.command,
|
|
229
|
+
args: options.args ?? [],
|
|
230
|
+
env,
|
|
231
|
+
// Route Registry stderr to the parent so operators can see provider logs.
|
|
232
|
+
stderr: "inherit",
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
const client = new Client({
|
|
236
|
+
name: "patchwork-mcp-app-server",
|
|
237
|
+
version: "0.1.0",
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
await client.connect(transport);
|
|
241
|
+
|
|
242
|
+
// Pre-load all tool metadata so ServiceBridge can be constructed synchronously.
|
|
243
|
+
const toolInfos = await loadRegistryToolInfos(client);
|
|
244
|
+
|
|
245
|
+
const backend: RegistryBackend = {
|
|
246
|
+
/**
|
|
247
|
+
* Execute a Registry-backed service tool.
|
|
248
|
+
*
|
|
249
|
+
* The call is forwarded as a Registry `call_tool` meta-tool invocation:
|
|
250
|
+
* namespace + "__" + procedure → Registry MCP tool name
|
|
251
|
+
*
|
|
252
|
+
* The first element of `args` is treated as the tool's argument object.
|
|
253
|
+
*/
|
|
254
|
+
async call(
|
|
255
|
+
namespace: string,
|
|
256
|
+
procedure: string,
|
|
257
|
+
args: unknown[],
|
|
258
|
+
): Promise<unknown> {
|
|
259
|
+
const toolName = `${namespace}__${procedure}`;
|
|
260
|
+
const toolArgs = (typeof args[0] === "object" && args[0] !== null ? args[0] : {}) as Record<
|
|
261
|
+
string,
|
|
262
|
+
unknown
|
|
263
|
+
>;
|
|
264
|
+
|
|
265
|
+
const result = (await client.callTool({
|
|
266
|
+
name: "call_tool",
|
|
267
|
+
arguments: {
|
|
268
|
+
tool_name: toolName,
|
|
269
|
+
arguments: toolArgs,
|
|
270
|
+
},
|
|
271
|
+
})) as McpToolResult;
|
|
272
|
+
|
|
273
|
+
if (result.isError) {
|
|
274
|
+
const message = result.content
|
|
275
|
+
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
276
|
+
.map((c) => c.text)
|
|
277
|
+
.join("\n");
|
|
278
|
+
throw new Error(message || `Registry call failed for '${toolName}'`);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Return parsed JSON when the response looks like JSON, else return raw text.
|
|
282
|
+
const textContent = result.content.find(
|
|
283
|
+
(c): c is { type: "text"; text: string } => c.type === "text",
|
|
284
|
+
);
|
|
285
|
+
if (textContent) {
|
|
286
|
+
try {
|
|
287
|
+
return JSON.parse(textContent.text) as unknown;
|
|
288
|
+
} catch {
|
|
289
|
+
return textContent.text;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return result;
|
|
294
|
+
},
|
|
295
|
+
|
|
296
|
+
getToolInfos(): ServiceToolInfo[] {
|
|
297
|
+
return toolInfos;
|
|
298
|
+
},
|
|
299
|
+
|
|
300
|
+
async close(): Promise<void> {
|
|
301
|
+
await client.close();
|
|
302
|
+
},
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
return backend;
|
|
306
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>Patchwork Widget Runtime</title>
|
|
7
|
+
<style>
|
|
8
|
+
* {
|
|
9
|
+
margin: 0;
|
|
10
|
+
padding: 0;
|
|
11
|
+
box-sizing: border-box;
|
|
12
|
+
}
|
|
13
|
+
html,
|
|
14
|
+
body {
|
|
15
|
+
width: 100%;
|
|
16
|
+
min-height: 100%;
|
|
17
|
+
font-family: system-ui, -apple-system, sans-serif;
|
|
18
|
+
}
|
|
19
|
+
#root {
|
|
20
|
+
width: 100%;
|
|
21
|
+
}
|
|
22
|
+
#pw-status {
|
|
23
|
+
padding: 12px 16px;
|
|
24
|
+
font-size: 13px;
|
|
25
|
+
color: #6b7280;
|
|
26
|
+
}
|
|
27
|
+
#pw-status[data-error] {
|
|
28
|
+
color: #dc2626;
|
|
29
|
+
white-space: pre-wrap;
|
|
30
|
+
}
|
|
31
|
+
</style>
|
|
32
|
+
</head>
|
|
33
|
+
<body>
|
|
34
|
+
<div id="pw-status">Loading runtime…</div>
|
|
35
|
+
<div id="root"></div>
|
|
36
|
+
<script type="module" src="./main.ts"></script>
|
|
37
|
+
</body>
|
|
38
|
+
</html>
|