@0xmaxma/claude-gateway 1.1.10 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +61 -1
  2. package/dist/agent/builtin-commands.d.ts +10 -0
  3. package/dist/agent/builtin-commands.d.ts.map +1 -0
  4. package/dist/agent/builtin-commands.js +38 -0
  5. package/dist/agent/builtin-commands.js.map +1 -0
  6. package/dist/agent/runner.d.ts +1 -4
  7. package/dist/agent/runner.d.ts.map +1 -1
  8. package/dist/agent/runner.js +25 -30
  9. package/dist/agent/runner.js.map +1 -1
  10. package/dist/agent/workspace-loader.d.ts.map +1 -1
  11. package/dist/agent/workspace-loader.js +9 -4
  12. package/dist/agent/workspace-loader.js.map +1 -1
  13. package/dist/api/apps-router.d.ts +7 -0
  14. package/dist/api/apps-router.d.ts.map +1 -0
  15. package/dist/api/apps-router.js +242 -0
  16. package/dist/api/apps-router.js.map +1 -0
  17. package/dist/api/gateway-router.d.ts +17 -1
  18. package/dist/api/gateway-router.d.ts.map +1 -1
  19. package/dist/api/gateway-router.js +171 -2
  20. package/dist/api/gateway-router.js.map +1 -1
  21. package/dist/api/router.js +2 -2
  22. package/dist/api/router.js.map +1 -1
  23. package/dist/apps/agent-manager.d.ts +76 -0
  24. package/dist/apps/agent-manager.d.ts.map +1 -0
  25. package/dist/apps/agent-manager.js +311 -0
  26. package/dist/apps/agent-manager.js.map +1 -0
  27. package/dist/apps/compose-generator.d.ts +108 -0
  28. package/dist/apps/compose-generator.d.ts.map +1 -0
  29. package/dist/apps/compose-generator.js +687 -0
  30. package/dist/apps/compose-generator.js.map +1 -0
  31. package/dist/apps/installer.d.ts +101 -0
  32. package/dist/apps/installer.d.ts.map +1 -0
  33. package/dist/apps/installer.js +898 -0
  34. package/dist/apps/installer.js.map +1 -0
  35. package/dist/apps/registry-client.d.ts +37 -0
  36. package/dist/apps/registry-client.d.ts.map +1 -0
  37. package/dist/apps/registry-client.js +106 -0
  38. package/dist/apps/registry-client.js.map +1 -0
  39. package/dist/apps/registry.d.ts +54 -0
  40. package/dist/apps/registry.d.ts.map +1 -0
  41. package/dist/apps/registry.js +182 -0
  42. package/dist/apps/registry.js.map +1 -0
  43. package/dist/apps/socket-server.d.ts +46 -0
  44. package/dist/apps/socket-server.d.ts.map +1 -0
  45. package/dist/apps/socket-server.js +297 -0
  46. package/dist/apps/socket-server.js.map +1 -0
  47. package/dist/config/watcher.d.ts.map +1 -1
  48. package/dist/config/watcher.js +35 -0
  49. package/dist/config/watcher.js.map +1 -1
  50. package/dist/index.js +145 -1
  51. package/dist/index.js.map +1 -1
  52. package/dist/session/process.d.ts.map +1 -1
  53. package/dist/session/process.js +47 -6
  54. package/dist/session/process.js.map +1 -1
  55. package/dist/types.d.ts +6 -0
  56. package/dist/types.d.ts.map +1 -1
  57. package/lib/pairing.ts +326 -0
  58. package/mcp/server.ts +2 -0
  59. package/mcp/tools/apps/client.ts +78 -0
  60. package/mcp/tools/apps/module.ts +211 -0
  61. package/mcp/tools/apps/skills/app-status/SKILL.md +48 -0
  62. package/mcp/tools/apps/skills/create-app-yaml/SKILL.md +94 -0
  63. package/mcp/tools/apps/skills/install-app/SKILL.md +102 -0
  64. package/mcp/tools/apps/skills/list-apps/SKILL.md +34 -0
  65. package/package.json +2 -1
package/lib/pairing.ts ADDED
@@ -0,0 +1,326 @@
1
+ /**
2
+ * Shared pairing primitives for both scripts/create-agent.ts and mcp/tools/agent/handlers.ts.
3
+ * Uses fetch (available in Node 18+ and Bun) — no https module dependency.
4
+ */
5
+
6
+ import * as fs from 'fs';
7
+ import * as path from 'path';
8
+ import { randomBytes } from 'crypto';
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Types
12
+ // ---------------------------------------------------------------------------
13
+
14
+ export interface TelegramFirstDM {
15
+ senderId: string;
16
+ chatId: string;
17
+ /** getUpdates offset to use for the next poll call */
18
+ nextOffset: number;
19
+ }
20
+
21
+ export interface DiscordFirstDM {
22
+ senderId: string;
23
+ channelId: string;
24
+ }
25
+
26
+ interface TgUpdate {
27
+ update_id: number;
28
+ message?: {
29
+ from: { id: number };
30
+ chat: { id: number; type: string };
31
+ text?: string;
32
+ };
33
+ }
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Telegram helpers
37
+ // ---------------------------------------------------------------------------
38
+
39
+ /**
40
+ * Long-poll Telegram getUpdates until the first private DM arrives.
41
+ * Returns sender/chat IDs plus the next polling offset.
42
+ */
43
+ export async function pollForFirstTelegramDM(
44
+ token: string,
45
+ timeoutMs = 10 * 60 * 1000,
46
+ ): Promise<TelegramFirstDM> {
47
+ const deadline = Date.now() + timeoutMs;
48
+ let offset = 0;
49
+
50
+ while (Date.now() < deadline) {
51
+ const remaining = Math.max(0, deadline - Date.now());
52
+ const pollSecs = Math.min(30, Math.ceil(remaining / 1000));
53
+ if (pollSecs === 0) break;
54
+
55
+ try {
56
+ const url = `https://api.telegram.org/bot${token}/getUpdates?offset=${offset}&timeout=${pollSecs}&allowed_updates=%5B%22message%22%5D`;
57
+ const res = await fetch(url);
58
+ const data = (await res.json()) as { ok: boolean; result: TgUpdate[] };
59
+
60
+ if (data.ok && data.result.length > 0) {
61
+ for (const update of data.result) {
62
+ offset = update.update_id + 1;
63
+ if (update.message?.chat.type === 'private') {
64
+ return {
65
+ senderId: String(update.message.from.id),
66
+ chatId: String(update.message.chat.id),
67
+ nextOffset: offset,
68
+ };
69
+ }
70
+ }
71
+ }
72
+ } catch {
73
+ await new Promise((r) => setTimeout(r, 2000));
74
+ }
75
+ }
76
+
77
+ throw new Error('Timed out waiting for first Telegram DM');
78
+ }
79
+
80
+ /**
81
+ * Poll getUpdates until the user in chatId replies with the expected code.
82
+ * Returns true if confirmed, false on timeout.
83
+ */
84
+ export async function pollForTelegramCode(
85
+ token: string,
86
+ chatId: string,
87
+ expectedCode: string,
88
+ offset: number,
89
+ timeoutMs = 3 * 60 * 1000,
90
+ ): Promise<boolean> {
91
+ const deadline = Date.now() + timeoutMs;
92
+ let currentOffset = offset;
93
+
94
+ while (Date.now() < deadline) {
95
+ const remaining = Math.max(0, deadline - Date.now());
96
+ const pollSecs = Math.min(30, Math.ceil(remaining / 1000));
97
+ if (pollSecs === 0) break;
98
+
99
+ try {
100
+ const url = `https://api.telegram.org/bot${token}/getUpdates?offset=${currentOffset}&timeout=${pollSecs}&allowed_updates=%5B%22message%22%5D`;
101
+ const res = await fetch(url);
102
+ const data = (await res.json()) as { ok: boolean; result: TgUpdate[] };
103
+
104
+ if (data.ok && data.result.length > 0) {
105
+ for (const update of data.result) {
106
+ currentOffset = update.update_id + 1;
107
+ if (
108
+ update.message?.chat.type === 'private' &&
109
+ String(update.message.chat.id) === chatId &&
110
+ update.message.text?.trim().toLowerCase() === expectedCode
111
+ ) {
112
+ return true;
113
+ }
114
+ }
115
+ }
116
+ } catch {
117
+ await new Promise((r) => setTimeout(r, 2000));
118
+ }
119
+ }
120
+
121
+ return false;
122
+ }
123
+
124
+ /** Send a message to a Telegram chat. Errors are swallowed (non-fatal). */
125
+ export async function sendTelegramMessage(
126
+ token: string,
127
+ chatId: string,
128
+ text: string,
129
+ ): Promise<void> {
130
+ try {
131
+ await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
132
+ method: 'POST',
133
+ headers: { 'Content-Type': 'application/json' },
134
+ body: JSON.stringify({ chat_id: chatId, text }),
135
+ });
136
+ } catch {
137
+ // Not fatal
138
+ }
139
+ }
140
+
141
+ // ---------------------------------------------------------------------------
142
+ // Discord helpers
143
+ // ---------------------------------------------------------------------------
144
+
145
+ /**
146
+ * Connect to the Discord gateway via WebSocket and wait for the first DM.
147
+ * Returns sender and channel IDs.
148
+ */
149
+ export async function pollForFirstDiscordDM(
150
+ token: string,
151
+ timeoutMs = 10 * 60 * 1000,
152
+ ): Promise<DiscordFirstDM> {
153
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
154
+ const WS = (globalThis as any).WebSocket as typeof WebSocket;
155
+ return new Promise((resolve, reject) => {
156
+ let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
157
+ let resolved = false;
158
+
159
+ const ws = new WS('wss://gateway.discord.gg/?v=10&encoding=json');
160
+
161
+ const deadline = setTimeout(() => {
162
+ ws.close();
163
+ reject(new Error('Pairing timeout — no Discord DM received within the allowed time'));
164
+ }, timeoutMs);
165
+
166
+ ws.onmessage = (event: MessageEvent) => {
167
+ const payload = JSON.parse(event.data as string) as {
168
+ op: number;
169
+ d: Record<string, unknown>;
170
+ t?: string;
171
+ };
172
+ const { op, d, t } = payload;
173
+
174
+ if (op === 10) {
175
+ const interval = (d.heartbeat_interval as number) ?? 41250;
176
+ heartbeatTimer = setInterval(() => ws.send(JSON.stringify({ op: 1, d: null })), interval);
177
+ ws.send(
178
+ JSON.stringify({
179
+ op: 2,
180
+ d: {
181
+ token,
182
+ intents: 4096 + 32768, // DIRECT_MESSAGES + MESSAGE_CONTENT
183
+ properties: { os: 'linux', browser: 'claude-gateway', device: 'claude-gateway' },
184
+ },
185
+ }),
186
+ );
187
+ } else if (op === 0 && t === 'MESSAGE_CREATE') {
188
+ const msg = d as Record<string, unknown>;
189
+ const author = msg['author'] as Record<string, unknown> | undefined;
190
+ if (!msg['guild_id'] && !author?.['bot']) {
191
+ if (!resolved) {
192
+ resolved = true;
193
+ clearTimeout(deadline);
194
+ if (heartbeatTimer) clearInterval(heartbeatTimer);
195
+ ws.close();
196
+ resolve({
197
+ senderId: String(author?.['id'] ?? ''),
198
+ channelId: String(msg['channel_id']),
199
+ });
200
+ }
201
+ }
202
+ }
203
+ };
204
+
205
+ ws.onerror = () => {
206
+ clearTimeout(deadline);
207
+ if (heartbeatTimer) clearInterval(heartbeatTimer);
208
+ reject(new Error('Discord WebSocket error during pairing'));
209
+ };
210
+ });
211
+ }
212
+
213
+ /** Send a message to a Discord channel. Errors are swallowed (non-fatal). */
214
+ export async function sendDiscordMessage(
215
+ token: string,
216
+ channelId: string,
217
+ text: string,
218
+ ): Promise<void> {
219
+ try {
220
+ await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, {
221
+ method: 'POST',
222
+ headers: {
223
+ 'Content-Type': 'application/json',
224
+ Authorization: `Bot ${token}`,
225
+ },
226
+ body: JSON.stringify({ content: text }),
227
+ });
228
+ } catch {
229
+ // Not fatal
230
+ }
231
+ }
232
+
233
+ // ---------------------------------------------------------------------------
234
+ // Access.json writers
235
+ // ---------------------------------------------------------------------------
236
+
237
+ export function writeTelegramAccess(stateDir: string, senderId: string): void {
238
+ fs.writeFileSync(
239
+ path.join(stateDir, 'access.json'),
240
+ JSON.stringify(
241
+ { dmPolicy: 'allowlist', allowFrom: [senderId], groups: {}, pending: {} },
242
+ null,
243
+ 2,
244
+ ),
245
+ { mode: 0o600 },
246
+ );
247
+ }
248
+
249
+ export function writeDiscordAccess(stateDir: string, senderId: string): void {
250
+ fs.writeFileSync(
251
+ path.join(stateDir, 'access.json'),
252
+ JSON.stringify(
253
+ {
254
+ dmPolicy: 'allowlist',
255
+ allowFrom: [senderId],
256
+ guildAllowlist: [],
257
+ channelAllowlist: [],
258
+ roleAllowlist: [],
259
+ pending: {},
260
+ },
261
+ null,
262
+ 2,
263
+ ) + '\n',
264
+ { mode: 0o600 },
265
+ );
266
+ }
267
+
268
+ // ---------------------------------------------------------------------------
269
+ // High-level non-interactive pairing (for MCP — no terminal readline)
270
+ // ---------------------------------------------------------------------------
271
+
272
+ /**
273
+ * Full non-interactive Telegram pairing:
274
+ * 1. Wait for first DM → 2. Send code → 3. Wait for user to reply with code → 4. Write access.json
275
+ */
276
+ export async function pairTelegramUser(
277
+ token: string,
278
+ stateDir: string,
279
+ timeoutMs = 8 * 60 * 1000,
280
+ ): Promise<{ chatId: string }> {
281
+ const { senderId, chatId, nextOffset } = await pollForFirstTelegramDM(token, timeoutMs);
282
+
283
+ const code = randomBytes(3).toString('hex');
284
+ await sendTelegramMessage(
285
+ token,
286
+ chatId,
287
+ `Pairing code: ${code}\n\nReply with this code to complete pairing.`,
288
+ );
289
+
290
+ const confirmed = await pollForTelegramCode(token, chatId, code, nextOffset, 3 * 60 * 1000);
291
+ if (!confirmed) {
292
+ throw new Error('Pairing timed out — user did not reply with the pairing code within 3 minutes');
293
+ }
294
+
295
+ writeTelegramAccess(stateDir, senderId);
296
+ await sendTelegramMessage(token, chatId, "You're connected! Send me a message to get started.");
297
+
298
+ return { chatId };
299
+ }
300
+
301
+ /**
302
+ * Full non-interactive Discord pairing:
303
+ * 1. Wait for first DM → 2. Send code → 3. Auto-approve (no HTTP polling for Discord code reply) → 4. Write access.json
304
+ *
305
+ * Discord does not support HTTP-based message polling, so code confirmation is skipped;
306
+ * the first DM sender is trusted as the owner.
307
+ */
308
+ export async function pairDiscordUser(
309
+ token: string,
310
+ stateDir: string,
311
+ timeoutMs = 8 * 60 * 1000,
312
+ ): Promise<{ channelId: string }> {
313
+ const { senderId, channelId } = await pollForFirstDiscordDM(token, timeoutMs);
314
+
315
+ const code = randomBytes(3).toString('hex');
316
+ await sendDiscordMessage(
317
+ token,
318
+ channelId,
319
+ `Pairing code: ${code}\n\nYou have been automatically paired as the bot owner.`,
320
+ );
321
+
322
+ writeDiscordAccess(stateDir, senderId);
323
+ await sendDiscordMessage(token, channelId, "You're connected! Send me a message to get started.");
324
+
325
+ return { channelId };
326
+ }
package/mcp/server.ts CHANGED
@@ -18,6 +18,7 @@ import { CronModule } from './tools/cron/module';
18
18
  import { SkillsModule } from './tools/skills/module';
19
19
  import { AgentModule } from './tools/agent/module';
20
20
  import { BrowserModule } from './tools/browser/module';
21
+ import { AppsModule } from './tools/apps/module';
21
22
  import type { ChannelModule, ToolModule, McpToolDefinition } from './types';
22
23
 
23
24
  const ORIGIN_CHANNEL = process.env.GATEWAY_ORIGIN_CHANNEL ?? '';
@@ -35,6 +36,7 @@ const modules: AnyModule[] = [
35
36
  new SkillsModule(),
36
37
  new AgentModule(),
37
38
  new BrowserModule(),
39
+ new AppsModule(),
38
40
  ];
39
41
 
40
42
  // Build tool-to-module mapping for enabled modules
@@ -0,0 +1,78 @@
1
+ /**
2
+ * HTTP client for the gateway apps REST API.
3
+ */
4
+
5
+ export class AppsClient {
6
+ private readonly baseUrl: string;
7
+ private readonly apiKey: string;
8
+
9
+ constructor(apiUrl: string, apiKey?: string) {
10
+ this.baseUrl = apiUrl.replace(/\/$/, '');
11
+ this.apiKey = apiKey ?? '';
12
+ }
13
+
14
+ private url(p: string): string {
15
+ return `${this.baseUrl}/api/v1/apps${p}`;
16
+ }
17
+
18
+ private async request(method: string, p: string, body?: unknown): Promise<unknown> {
19
+ const headers: Record<string, string> = {};
20
+ if (body) headers['Content-Type'] = 'application/json';
21
+ if (this.apiKey) headers['Authorization'] = `Bearer ${this.apiKey}`;
22
+
23
+ const res = await fetch(this.url(p), {
24
+ method,
25
+ headers,
26
+ body: body ? JSON.stringify(body) : undefined,
27
+ });
28
+
29
+ if (!res.ok) {
30
+ const text = await res.text().catch(() => '');
31
+ throw new Error(`Apps API ${method} ${p} failed: HTTP ${res.status} ${text}`);
32
+ }
33
+
34
+ const ct = res.headers.get('content-type') ?? '';
35
+ if (ct.includes('application/json')) return res.json();
36
+ return res.text();
37
+ }
38
+
39
+ async listRegistry(): Promise<unknown> {
40
+ return this.request('GET', '/registry');
41
+ }
42
+
43
+ async getRegistry(name: string): Promise<unknown> {
44
+ return this.request('GET', `/registry/${encodeURIComponent(name)}`);
45
+ }
46
+
47
+ async listApps(): Promise<unknown> {
48
+ return this.request('GET', '');
49
+ }
50
+
51
+ async getApp(name: string): Promise<unknown> {
52
+ return this.request('GET', `/${encodeURIComponent(name)}`);
53
+ }
54
+
55
+ async getVersion(name: string): Promise<unknown> {
56
+ return this.request('GET', `/${encodeURIComponent(name)}/version`);
57
+ }
58
+
59
+ async install(params: Record<string, unknown>): Promise<unknown> {
60
+ return this.request('POST', '/install', params);
61
+ }
62
+
63
+ async pollJob(jobId: string): Promise<unknown> {
64
+ return this.request('GET', `/jobs/${encodeURIComponent(jobId)}`);
65
+ }
66
+
67
+ async uninstall(name: string): Promise<unknown> {
68
+ return this.request('DELETE', `/${encodeURIComponent(name)}`);
69
+ }
70
+
71
+ async update(name: string): Promise<unknown> {
72
+ return this.request('POST', `/${encodeURIComponent(name)}/update`);
73
+ }
74
+
75
+ async startStop(name: string, action: 'start' | 'stop' | 'restart'): Promise<unknown> {
76
+ return this.request('POST', `/${encodeURIComponent(name)}/${action}`);
77
+ }
78
+ }
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Apps tool module — install, list, and manage app store apps via gateway REST API.
3
+ */
4
+
5
+ import * as path from 'path';
6
+ import type {
7
+ ToolModule,
8
+ McpToolDefinition,
9
+ McpToolResult,
10
+ ToolVisibility,
11
+ } from '../../types';
12
+ import { AppsClient } from './client';
13
+
14
+ export class AppsModule implements ToolModule {
15
+ id = 'apps';
16
+ toolVisibility: ToolVisibility = 'all-configured';
17
+ skillsDir = path.join(__dirname, 'skills');
18
+
19
+ private client: AppsClient | null = null;
20
+
21
+ isEnabled(): boolean {
22
+ return Boolean(process.env.GATEWAY_API_URL);
23
+ }
24
+
25
+ private getClient(): AppsClient {
26
+ if (!this.client) {
27
+ const apiUrl = process.env.GATEWAY_API_URL!;
28
+ const apiKey = process.env.GATEWAY_API_KEY;
29
+ this.client = new AppsClient(apiUrl, apiKey);
30
+ }
31
+ return this.client;
32
+ }
33
+
34
+ getTools(): McpToolDefinition[] {
35
+ return [
36
+ {
37
+ name: 'browse_registry',
38
+ description: 'Browse the community app registry. Omit name to list all apps; provide name to get versions for a specific app.',
39
+ inputSchema: {
40
+ type: 'object',
41
+ properties: {
42
+ name: { type: 'string', description: 'App name to look up. Omit to list all.' },
43
+ },
44
+ additionalProperties: false,
45
+ },
46
+ },
47
+ {
48
+ name: 'install_app',
49
+ description: 'Install an app from the registry or a GitHub URL. Returns a jobId to poll with poll_install_job.',
50
+ inputSchema: {
51
+ type: 'object',
52
+ properties: {
53
+ registry_app: { type: 'string', description: 'Registry app name (e.g. "getpod-manager")' },
54
+ version: { type: 'string', description: 'Registry version to install (default: latest)' },
55
+ github_url: { type: 'string', description: 'GitHub repo URL (requires commit)' },
56
+ commit: { type: 'string', description: '40-char hex commit hash (required with github_url)' },
57
+ local_path: { type: 'string', description: 'Local app path within ~/.claude-gateway/apps/' },
58
+ env_vars: {
59
+ type: 'object',
60
+ description: 'Environment variables / secrets to inject',
61
+ additionalProperties: { type: 'string' },
62
+ },
63
+ },
64
+ additionalProperties: false,
65
+ },
66
+ },
67
+ {
68
+ name: 'poll_install_job',
69
+ description: 'Poll the status of an app install job. Returns status (pending/running/completed/failed), logs, and result on completion.',
70
+ inputSchema: {
71
+ type: 'object',
72
+ properties: {
73
+ job_id: { type: 'string', description: 'Job ID returned by install_app' },
74
+ },
75
+ required: ['job_id'],
76
+ additionalProperties: false,
77
+ },
78
+ },
79
+ {
80
+ name: 'list_apps',
81
+ description: 'List all installed apps with their status and proxy URLs.',
82
+ inputSchema: {
83
+ type: 'object',
84
+ properties: {},
85
+ additionalProperties: false,
86
+ },
87
+ },
88
+ {
89
+ name: 'app_status',
90
+ description: 'Get detailed status and version info for an installed app.',
91
+ inputSchema: {
92
+ type: 'object',
93
+ properties: {
94
+ name: { type: 'string', description: 'App name' },
95
+ },
96
+ required: ['name'],
97
+ additionalProperties: false,
98
+ },
99
+ },
100
+ {
101
+ name: 'update_app',
102
+ description: 'Update a registry-installed app to its latest version. Returns a jobId.',
103
+ inputSchema: {
104
+ type: 'object',
105
+ properties: {
106
+ name: { type: 'string', description: 'App name to update' },
107
+ },
108
+ required: ['name'],
109
+ additionalProperties: false,
110
+ },
111
+ },
112
+ {
113
+ name: 'uninstall_app',
114
+ description: 'Uninstall an app — stops containers, removes images, deletes files.',
115
+ inputSchema: {
116
+ type: 'object',
117
+ properties: {
118
+ name: { type: 'string', description: 'App name to uninstall' },
119
+ },
120
+ required: ['name'],
121
+ additionalProperties: false,
122
+ },
123
+ },
124
+ {
125
+ name: 'start_stop_app',
126
+ description: 'Start, stop, or restart an installed app.',
127
+ inputSchema: {
128
+ type: 'object',
129
+ properties: {
130
+ name: { type: 'string', description: 'App name' },
131
+ action: { type: 'string', enum: ['start', 'stop', 'restart'], description: 'Action to perform' },
132
+ },
133
+ required: ['name', 'action'],
134
+ additionalProperties: false,
135
+ },
136
+ },
137
+ ];
138
+ }
139
+
140
+ async handleTool(name: string, args: Record<string, unknown>): Promise<McpToolResult> {
141
+ const client = this.getClient();
142
+
143
+ switch (name) {
144
+ case 'browse_registry': {
145
+ const appName = args['name'] as string | undefined;
146
+ const data = appName
147
+ ? await client.getRegistry(appName)
148
+ : await client.listRegistry();
149
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
150
+ }
151
+
152
+ case 'install_app': {
153
+ const params: Record<string, unknown> = {};
154
+ if (args['registry_app']) params['registry_app'] = args['registry_app'];
155
+ if (args['version']) params['version'] = args['version'];
156
+ if (args['github_url']) params['github_url'] = args['github_url'];
157
+ if (args['commit']) params['commit'] = args['commit'];
158
+ if (args['local_path']) params['local_path'] = args['local_path'];
159
+ if (args['env_vars']) params['env_vars'] = args['env_vars'];
160
+ const data = await client.install(params);
161
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
162
+ }
163
+
164
+ case 'poll_install_job': {
165
+ const jobId = args['job_id'] as string;
166
+ const data = await client.pollJob(jobId);
167
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
168
+ }
169
+
170
+ case 'list_apps': {
171
+ const data = await client.listApps();
172
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
173
+ }
174
+
175
+ case 'app_status': {
176
+ const appName = args['name'] as string;
177
+ const [entry, version] = await Promise.all([
178
+ client.getApp(appName),
179
+ client.getVersion(appName).catch(() => null),
180
+ ]);
181
+ const result = { ...(entry as object), version_info: version };
182
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
183
+ }
184
+
185
+ case 'update_app': {
186
+ const appName = args['name'] as string;
187
+ const data = await client.update(appName);
188
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
189
+ }
190
+
191
+ case 'uninstall_app': {
192
+ const appName = args['name'] as string;
193
+ const data = await client.uninstall(appName);
194
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
195
+ }
196
+
197
+ case 'start_stop_app': {
198
+ const appName = args['name'] as string;
199
+ const action = args['action'] as 'start' | 'stop' | 'restart';
200
+ const data = await client.startStop(appName, action);
201
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
202
+ }
203
+
204
+ default:
205
+ return {
206
+ content: [{ type: 'text', text: `Unknown tool: ${name}` }],
207
+ isError: true,
208
+ };
209
+ }
210
+ }
211
+ }
@@ -0,0 +1,48 @@
1
+ ---
2
+ name: app-status
3
+ description: Show detailed status, version info, and update availability for an installed app.
4
+ user-invocable: true
5
+ allowed-tools:
6
+ - mcp__gateway__app_status
7
+ ---
8
+
9
+ # /app-status — App Status
10
+
11
+ Arguments passed: `$ARGUMENTS`
12
+
13
+ ---
14
+
15
+ ## Usage
16
+
17
+ `/app-status <app-name>`
18
+
19
+ ---
20
+
21
+ ## Steps
22
+
23
+ 1. Call `app_status` with the app name.
24
+
25
+ 2. Format and present:
26
+
27
+ ```
28
+ App: <name>
29
+ Status: running ✓ (or stopped / error / building)
30
+ Version: 1.2.0
31
+ Commit: abc123de
32
+ Source: registry (or custom / local)
33
+ Installed: 2026-05-01T10:00:00Z
34
+
35
+ Proxy routes:
36
+ api → /app/<name>/api/
37
+ web → /app/<name>/web/
38
+
39
+ Version info:
40
+ Installed: 1.2.0
41
+ Latest: 1.3.0 ← update available
42
+ Behind: yes
43
+ ```
44
+
45
+ 3. If an update is available and source is `registry`, offer:
46
+ > Run `/install-app <name>` after uninstalling, or use the update API.
47
+
48
+ If the app is not found (404), say so clearly.