@aion0/forge 0.10.47 → 0.10.48
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/RELEASE_NOTES.md +5 -5
- package/app/api/auth/keys/[id]/route.ts +16 -0
- package/app/api/auth/keys/route.ts +36 -0
- package/app/api/mcp/route.ts +144 -0
- package/cli/key.ts +67 -0
- package/cli/mcp-install.ts +106 -0
- package/cli/mcp-proxy.ts +196 -0
- package/cli/mw.mjs +453 -35
- package/cli/mw.ts +26 -1
- package/components/SettingsModal.tsx +123 -0
- package/lib/api-auth.ts +50 -0
- package/lib/api-keys.ts +157 -0
- package/lib/jobs/store.ts +25 -0
- package/lib/projects.ts +79 -5
- package/lib/settings.ts +12 -2
- package/mcp/README.md +46 -0
- package/mcp/server.ts +30 -0
- package/mcp/tools/_shared.ts +103 -0
- package/mcp/tools/automation.ts +244 -0
- package/mcp/tools/connectors.ts +83 -0
- package/mcp/tools/help.ts +50 -0
- package/mcp/tools/index.ts +39 -0
- package/mcp/tools/integrations.ts +97 -0
- package/mcp/tools/logs.ts +57 -0
- package/mcp/tools/marketplace.ts +75 -0
- package/mcp/tools/observability.ts +96 -0
- package/mcp/tools/pipelines.ts +150 -0
- package/mcp/tools/projects.ts +54 -0
- package/mcp/tools/tasks.ts +93 -0
- package/mcp/tools/workspace.ts +94 -0
- package/package.json +1 -1
- package/proxy.ts +27 -16
- package/src/core/db/database.ts +50 -43
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
# Forge v0.10.
|
|
1
|
+
# Forge v0.10.48
|
|
2
2
|
|
|
3
3
|
Released: 2026-06-08
|
|
4
4
|
|
|
5
|
-
## Changes since v0.10.
|
|
5
|
+
## Changes since v0.10.47
|
|
6
6
|
|
|
7
7
|
### Other
|
|
8
|
-
-
|
|
9
|
-
-
|
|
8
|
+
- fix(proxy): drop runtime:'nodejs' — Next.js 16 errors on it
|
|
9
|
+
- Implement an MCP layer for forge management (#34)
|
|
10
10
|
|
|
11
11
|
|
|
12
|
-
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.
|
|
12
|
+
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.47...v0.10.48
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
import { verifyAdmin } from '@/lib/password';
|
|
3
|
+
import { revokeApiKey } from '@/lib/api-keys';
|
|
4
|
+
|
|
5
|
+
// DELETE /api/auth/keys/:id — revoke a key; requires the admin password (in body)
|
|
6
|
+
export async function DELETE(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
7
|
+
const { id } = await params;
|
|
8
|
+
const body = await req.json().catch(() => ({}));
|
|
9
|
+
const { adminPassword } = body as { adminPassword?: string };
|
|
10
|
+
|
|
11
|
+
if (!verifyAdmin(adminPassword || '')) {
|
|
12
|
+
return NextResponse.json({ error: 'Invalid admin password' }, { status: 403 });
|
|
13
|
+
}
|
|
14
|
+
const ok = revokeApiKey(id);
|
|
15
|
+
return NextResponse.json({ ok });
|
|
16
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
import { verifyAdmin, getAdminPassword } from '@/lib/password';
|
|
3
|
+
import { createApiKey, listApiKeys } from '@/lib/api-keys';
|
|
4
|
+
import { isAuthenticated } from '@/lib/api-auth';
|
|
5
|
+
import { isValidToken } from '@/app/api/auth/verify/route';
|
|
6
|
+
|
|
7
|
+
function isDev(): boolean {
|
|
8
|
+
return process.env.NODE_ENV !== 'production' || process.env.FORGE_DEV === '1';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Reads require an authenticated caller (browser session, valid key, or dev). */
|
|
12
|
+
async function canRead(req: Request): Promise<boolean> {
|
|
13
|
+
return isDev() || (await isAuthenticated(req)) || isValidToken(req);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// GET /api/auth/keys — list keys (no secrets)
|
|
17
|
+
export async function GET(req: Request) {
|
|
18
|
+
if (!(await canRead(req))) return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
|
|
19
|
+
return NextResponse.json({ keys: listApiKeys(), adminConfigured: !!getAdminPassword() });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// POST /api/auth/keys — mint a key; requires the admin password
|
|
23
|
+
export async function POST(req: Request) {
|
|
24
|
+
const body = await req.json().catch(() => ({}));
|
|
25
|
+
const { name, adminPassword } = body as { name?: string; adminPassword?: string };
|
|
26
|
+
|
|
27
|
+
if (!getAdminPassword()) {
|
|
28
|
+
return NextResponse.json({ error: 'Set an admin password first' }, { status: 400 });
|
|
29
|
+
}
|
|
30
|
+
if (!verifyAdmin(adminPassword || '')) {
|
|
31
|
+
return NextResponse.json({ error: 'Invalid admin password' }, { status: 403 });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const { key, record } = createApiKey(name || 'agent key');
|
|
35
|
+
return NextResponse.json({ key, record });
|
|
36
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forge Management MCP — HTTP endpoint (Streamable HTTP transport).
|
|
3
|
+
*
|
|
4
|
+
* Hosts `createManagementMcpServer()` inside Next.js so it inherits Forge's
|
|
5
|
+
* auth middleware and the nginx reverse proxy — no new port, no new auth surface.
|
|
6
|
+
*
|
|
7
|
+
* Uses the SDK's Web-standard transport, whose handleRequest(Request) → Response
|
|
8
|
+
* fits an app-router handler directly. Stateful/session-keyed: the initialize
|
|
9
|
+
* handshake mints a session id that ties later calls together. (Stateless mode
|
|
10
|
+
* skips session validation but forbids transport reuse, so it can't hold a
|
|
11
|
+
* multi-call session.) `enableJsonResponse` gives the stdio proxy plain JSON
|
|
12
|
+
* back per request.
|
|
13
|
+
*
|
|
14
|
+
* The `forge mcp` stdio proxy (cli/mcp-proxy.ts) is the intended client: it
|
|
15
|
+
* forwards JSON-RPC frames here and carries the Mcp-Session-Id header.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { isValidToken } from '@/app/api/auth/verify/route';
|
|
19
|
+
import { isAuthenticated } from '@/lib/api-auth';
|
|
20
|
+
import { createManagementMcpServer } from '@/mcp/server';
|
|
21
|
+
import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
|
|
22
|
+
import { randomUUID } from 'node:crypto';
|
|
23
|
+
|
|
24
|
+
export const runtime = 'nodejs';
|
|
25
|
+
|
|
26
|
+
// A live session: the SDK transport plus when we last saw traffic (for idle eviction).
|
|
27
|
+
interface Session {
|
|
28
|
+
transport: WebStandardStreamableHTTPServerTransport;
|
|
29
|
+
lastSeen: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const SESSION_IDLE_MS = 30 * 60_000; // evict sessions idle longer than 30 min
|
|
33
|
+
const SESSION_MAX = 256; // hard cap; evict oldest beyond this
|
|
34
|
+
|
|
35
|
+
// Survive HMR / multiple route module evaluations (same pattern as the rest of Forge).
|
|
36
|
+
const KEY = Symbol.for('forge-management-mcp-sessions');
|
|
37
|
+
const g = globalThis as unknown as Record<symbol, Map<string, Session>>;
|
|
38
|
+
if (!g[KEY]) g[KEY] = new Map();
|
|
39
|
+
const sessions = g[KEY];
|
|
40
|
+
|
|
41
|
+
/** A real MCP client (not the proxy) typically closes its session with DELETE,
|
|
42
|
+
* but the stdio proxy may exit without one. Lazily reap idle/overflow sessions
|
|
43
|
+
* so the module-global Map can't grow unbounded on a long-running server. */
|
|
44
|
+
function reap(): void {
|
|
45
|
+
const now = Date.now();
|
|
46
|
+
for (const [id, s] of sessions) {
|
|
47
|
+
if (now - s.lastSeen > SESSION_IDLE_MS) {
|
|
48
|
+
try { s.transport.close(); } catch { /* best-effort */ }
|
|
49
|
+
sessions.delete(id);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (sessions.size > SESSION_MAX) {
|
|
53
|
+
const oldest = [...sessions.entries()].sort((a, b) => a[1].lastSeen - b[1].lastSeen);
|
|
54
|
+
for (const [id, s] of oldest.slice(0, sessions.size - SESSION_MAX)) {
|
|
55
|
+
try { s.transport.close(); } catch { /* best-effort */ }
|
|
56
|
+
sessions.delete(id);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Returns a 401 Response if the request is not authorized, else null. */
|
|
62
|
+
async function denied(req: Request): Promise<Response | null> {
|
|
63
|
+
const isDev = process.env.NODE_ENV !== 'production' || process.env.FORGE_DEV === '1';
|
|
64
|
+
if (isDev) return null;
|
|
65
|
+
// A valid API key (Bearer/x-forge-token) or validated browser session, or the
|
|
66
|
+
// legacy admin-password-minted token.
|
|
67
|
+
if ((await isAuthenticated(req)) || isValidToken(req)) return null;
|
|
68
|
+
return Response.json({ error: 'unauthorized' }, { status: 401 });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Streamable-HTTP's signal for a stale/unknown session id: the client should
|
|
72
|
+
* discard it and re-initialize. Shaped as a JSON-RPC error so even a non-proxy
|
|
73
|
+
* client gets a usable body, with the 404 status the spec expects. */
|
|
74
|
+
function sessionNotFound(): Response {
|
|
75
|
+
return Response.json(
|
|
76
|
+
{ jsonrpc: '2.0', error: { code: -32001, message: 'Session not found' }, id: null },
|
|
77
|
+
{ status: 404 },
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function POST(req: Request): Promise<Response> {
|
|
82
|
+
const d = await denied(req);
|
|
83
|
+
if (d) return d;
|
|
84
|
+
reap();
|
|
85
|
+
|
|
86
|
+
const sid = req.headers.get('mcp-session-id') || '';
|
|
87
|
+
if (sid) {
|
|
88
|
+
// A session id was supplied — it MUST already exist. If it doesn't (server
|
|
89
|
+
// restarted, HMR reset, idle-evicted), reject with 404 instead of silently
|
|
90
|
+
// building a fresh, uninitialized transport (which would surface a confusing
|
|
91
|
+
// 400 "Server not initialized" and leak an un-stored transport).
|
|
92
|
+
const s = sessions.get(sid);
|
|
93
|
+
if (!s) return sessionNotFound();
|
|
94
|
+
s.lastSeen = Date.now();
|
|
95
|
+
return s.transport.handleRequest(req);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// No session id → this MUST be an `initialize` request. Anything else (a stale
|
|
99
|
+
// client after a restart, a stray frame) would otherwise build a transport that
|
|
100
|
+
// onsessioninitialized never stores — a leaked connected pair surfaced as a
|
|
101
|
+
// confusing 400. Reject it with the same 404 the unknown-session path returns.
|
|
102
|
+
let initBody: any;
|
|
103
|
+
try { initBody = await req.clone().json(); } catch { initBody = null; }
|
|
104
|
+
const isInit = Array.isArray(initBody)
|
|
105
|
+
? initBody.some((m) => m && m.method === 'initialize')
|
|
106
|
+
: !!(initBody && initBody.method === 'initialize');
|
|
107
|
+
if (!isInit) return sessionNotFound();
|
|
108
|
+
|
|
109
|
+
let transport: WebStandardStreamableHTTPServerTransport;
|
|
110
|
+
transport = new WebStandardStreamableHTTPServerTransport({
|
|
111
|
+
sessionIdGenerator: () => randomUUID(),
|
|
112
|
+
enableJsonResponse: true,
|
|
113
|
+
onsessioninitialized: (id) => { sessions.set(id, { transport, lastSeen: Date.now() }); },
|
|
114
|
+
});
|
|
115
|
+
transport.onclose = () => {
|
|
116
|
+
const id = transport.sessionId;
|
|
117
|
+
if (id) sessions.delete(id);
|
|
118
|
+
};
|
|
119
|
+
const server = createManagementMcpServer();
|
|
120
|
+
await server.connect(transport);
|
|
121
|
+
return transport.handleRequest(req);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function GET(req: Request): Promise<Response> {
|
|
125
|
+
const d = await denied(req);
|
|
126
|
+
if (d) return d;
|
|
127
|
+
reap();
|
|
128
|
+
const sid = req.headers.get('mcp-session-id') || '';
|
|
129
|
+
const s = sid ? sessions.get(sid) : undefined;
|
|
130
|
+
if (!s) return sessionNotFound();
|
|
131
|
+
s.lastSeen = Date.now();
|
|
132
|
+
return s.transport.handleRequest(req);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function DELETE(req: Request): Promise<Response> {
|
|
136
|
+
const d = await denied(req);
|
|
137
|
+
if (d) return d;
|
|
138
|
+
reap();
|
|
139
|
+
const sid = req.headers.get('mcp-session-id') || '';
|
|
140
|
+
const s = sid ? sessions.get(sid) : undefined;
|
|
141
|
+
if (!s) return sessionNotFound();
|
|
142
|
+
// handleRequest → transport.close() → onclose deletes the entry from `sessions`.
|
|
143
|
+
return s.transport.handleRequest(req);
|
|
144
|
+
}
|
package/cli/key.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* forge key — manage API keys (service/agent credentials, incl. MCP).
|
|
3
|
+
*
|
|
4
|
+
* forge key create [name] [--save] mint a key
|
|
5
|
+
* forge key list list keys
|
|
6
|
+
* forge key revoke <id> revoke a key
|
|
7
|
+
*
|
|
8
|
+
* Operates directly on the local key store (<dataDir>/api-keys.json) — running
|
|
9
|
+
* the `forge` CLI already proves local filesystem access, so no admin password is
|
|
10
|
+
* needed (the HTTP route still requires it; that's the remote/browser path). The
|
|
11
|
+
* running server picks up changes on its next request (its key cache
|
|
12
|
+
* self-invalidates on the file's mtime). Set FORGE_DATA_DIR to target a
|
|
13
|
+
* non-default instance's data dir.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { writeFileSync, mkdirSync } from 'node:fs';
|
|
17
|
+
import { homedir } from 'node:os';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
import { createApiKey, listApiKeys, revokeApiKey } from '../lib/api-keys';
|
|
20
|
+
|
|
21
|
+
export async function keyCommand(args: string[]): Promise<void> {
|
|
22
|
+
const sub = args[0];
|
|
23
|
+
const positional = args.slice(1).filter((a) => !a.startsWith('--'));
|
|
24
|
+
|
|
25
|
+
if (sub === 'list') {
|
|
26
|
+
const keys = listApiKeys();
|
|
27
|
+
if (!keys.length) { console.log('No API keys. Create one: forge key create <name>'); return; }
|
|
28
|
+
for (const k of keys) {
|
|
29
|
+
const used = k.lastUsedAt ? k.lastUsedAt.slice(0, 10) : 'never';
|
|
30
|
+
console.log(` ${k.id} ${String(k.prefix).padEnd(20)} ${k.name} (created ${k.createdAt?.slice(0, 10)}, last used ${used})`);
|
|
31
|
+
}
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (sub === 'create') {
|
|
36
|
+
const name = positional[0] || 'agent key';
|
|
37
|
+
const { key } = createApiKey(name);
|
|
38
|
+
console.log(`\n✓ API key created (${name}):\n\n ${key}\n`);
|
|
39
|
+
console.log(' Copy it now — it will not be shown again.');
|
|
40
|
+
if (args.includes('--save')) {
|
|
41
|
+
const dir = join(homedir(), '.forge');
|
|
42
|
+
mkdirSync(dir, { recursive: true });
|
|
43
|
+
const f = join(dir, 'mcp-key');
|
|
44
|
+
writeFileSync(f, key, { mode: 0o600 });
|
|
45
|
+
console.log(` Saved to ${f} — forge mcp will read it automatically.`);
|
|
46
|
+
} else {
|
|
47
|
+
console.log(' Use it: export FORGE_API_KEY=<key> (or rerun with --save)');
|
|
48
|
+
}
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (sub === 'revoke') {
|
|
53
|
+
const id = positional[0];
|
|
54
|
+
if (!id) { console.log('Usage: forge key revoke <id>'); process.exit(1); }
|
|
55
|
+
console.log(revokeApiKey(id) ? `✓ Revoked key ${id}` : `Key ${id} not found`);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
console.log(`forge key — manage API keys (service/agent credentials, incl. MCP)
|
|
60
|
+
|
|
61
|
+
forge key create [name] [--save] Mint a key (--save writes it to ~/.forge/mcp-key)
|
|
62
|
+
forge key list List keys
|
|
63
|
+
forge key revoke <id> Revoke a key
|
|
64
|
+
|
|
65
|
+
No admin password — operates on the local key store directly.
|
|
66
|
+
Set FORGE_DATA_DIR to target a non-default instance.`);
|
|
67
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `forge mcp install` — add the Forge Management MCP to Claude Code.
|
|
3
|
+
*
|
|
4
|
+
* Mints an API key if one isn't already saved, then runs `claude mcp add` — so a
|
|
5
|
+
* user can just say "add forge management MCP" and the agent runs one command.
|
|
6
|
+
*
|
|
7
|
+
* No admin password: running the `forge` CLI already proves filesystem access to
|
|
8
|
+
* the data dir, so the key is minted directly into <dataDir>/api-keys.json. The
|
|
9
|
+
* running server picks it up on its next request (its key cache self-invalidates
|
|
10
|
+
* on the file's mtime), and the server need not even be up. (A loopback-IP check
|
|
11
|
+
* would be wrong — Forge runs behind nginx, so remote users also arrive over
|
|
12
|
+
* loopback; filesystem access is the only trustworthy local signal.)
|
|
13
|
+
*
|
|
14
|
+
* The store is getDataDir() — FORGE_DATA_DIR, else ~/.forge/data — so set
|
|
15
|
+
* FORGE_DATA_DIR to mint into a non-default instance's data dir.
|
|
16
|
+
*
|
|
17
|
+
* A --remote install can't reach the remote's data dir, so it uses a key you
|
|
18
|
+
* already hold (FORGE_API_KEY, the saved key file, or --http --header).
|
|
19
|
+
*
|
|
20
|
+
* forge mcp install stdio proxy, local server
|
|
21
|
+
* forge mcp install --http register the HTTP transport instead
|
|
22
|
+
* forge mcp install --scope user available in every project
|
|
23
|
+
* forge mcp install --remote URL target a remote Forge (needs FORGE_API_KEY)
|
|
24
|
+
* forge mcp install --print print the `claude mcp add` command, don't run it
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { spawnSync } from 'node:child_process';
|
|
28
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
29
|
+
import { homedir } from 'node:os';
|
|
30
|
+
import { join } from 'node:path';
|
|
31
|
+
import { createApiKey } from '../lib/api-keys';
|
|
32
|
+
|
|
33
|
+
const opt = (args: string[], name: string): string | undefined => {
|
|
34
|
+
const i = args.indexOf(name);
|
|
35
|
+
return i >= 0 ? args[i + 1] : undefined;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const keyFile = (): string => join(homedir(), '.forge', 'mcp-key');
|
|
39
|
+
|
|
40
|
+
/** A key already available from env or the Forge-owned key file, if any. */
|
|
41
|
+
function savedKey(): string {
|
|
42
|
+
const env = (process.env.FORGE_API_KEY || process.env.FORGE_API_TOKEN || '').trim();
|
|
43
|
+
if (env) return env;
|
|
44
|
+
try { if (existsSync(keyFile())) return readFileSync(keyFile(), 'utf-8').trim(); } catch { /* ignore */ }
|
|
45
|
+
return '';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Return a usable key, minting one locally if none is saved.
|
|
49
|
+
* Local mint writes <dataDir>/api-keys.json (no password, no running server).
|
|
50
|
+
* Remote installs can't mint here, so a saved key is required. */
|
|
51
|
+
function ensureKey(remote: string | undefined): string {
|
|
52
|
+
const existing = savedKey();
|
|
53
|
+
if (existing) return existing;
|
|
54
|
+
if (remote) throw new Error("remote install needs a key — set FORGE_API_KEY (mint one in the remote's Settings → API Key)");
|
|
55
|
+
|
|
56
|
+
const { key } = createApiKey('claude-code');
|
|
57
|
+
mkdirSync(join(homedir(), '.forge'), { recursive: true });
|
|
58
|
+
writeFileSync(keyFile(), key, { mode: 0o600 });
|
|
59
|
+
console.error(`[forge mcp install] minted a local API key → ${keyFile()}`);
|
|
60
|
+
return key;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function mcpInstallCommand(args: string[]): Promise<void> {
|
|
64
|
+
const name = opt(args, '--name') || 'forge';
|
|
65
|
+
const scope = opt(args, '--scope');
|
|
66
|
+
const remote = opt(args, '--remote');
|
|
67
|
+
const useHttp = args.includes('--http');
|
|
68
|
+
const printOnly = args.includes('--print');
|
|
69
|
+
const port = opt(args, '--port');
|
|
70
|
+
const base = (remote || process.env.MW_URL || `http://localhost:${port || '8403'}`).replace(/\/$/, '');
|
|
71
|
+
|
|
72
|
+
// A local mint lands in getDataDir() (FORGE_DATA_DIR or ~/.forge/data), which
|
|
73
|
+
// ignores --port/MW_URL. If those target a non-default instance, the key would
|
|
74
|
+
// go to the wrong store and silently 401 — refuse with guidance instead.
|
|
75
|
+
if (!printOnly && !remote && !savedKey() && !process.env.FORGE_DATA_DIR
|
|
76
|
+
&& (process.env.MW_URL || (port && port !== '8403'))) {
|
|
77
|
+
throw new Error('targeting a non-default instance — set FORGE_DATA_DIR to its data dir so the key is minted into the store that server reads');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// --print is a dry run: don't mint anything, just show the command.
|
|
81
|
+
const key = printOnly ? savedKey() : ensureKey(remote);
|
|
82
|
+
|
|
83
|
+
const add = ['mcp', 'add'];
|
|
84
|
+
if (scope) add.push('--scope', scope);
|
|
85
|
+
if (useHttp) {
|
|
86
|
+
add.push('--transport', 'http', name, `${base}/api/mcp`);
|
|
87
|
+
if (key) add.push('--header', `Authorization: Bearer ${key}`);
|
|
88
|
+
else if (printOnly) add.push('--header', 'Authorization: Bearer forge_sk_YOUR_KEY');
|
|
89
|
+
} else {
|
|
90
|
+
// The stdio proxy reads ~/.forge/mcp-key itself; only forward MW_URL when non-default.
|
|
91
|
+
if (remote || process.env.MW_URL) add.push('--env', `MW_URL=${base}`);
|
|
92
|
+
add.push(name, '--', 'forge', 'mcp');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const pretty = `claude ${add.map((a) => (a.includes(' ') ? JSON.stringify(a) : a)).join(' ')}`;
|
|
96
|
+
if (printOnly) { console.log(pretty); return; }
|
|
97
|
+
|
|
98
|
+
const r = spawnSync('claude', add, { stdio: 'inherit' });
|
|
99
|
+
if (r.error && (r.error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
100
|
+
console.error('[forge mcp install] the `claude` CLI is not on PATH. Run this yourself:\n');
|
|
101
|
+
console.log(` ${pretty}\n`);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (r.status && r.status !== 0) process.exit(r.status);
|
|
105
|
+
console.error(`\n[forge mcp install] added '${name}'. Run /mcp in a Claude Code session to confirm.`);
|
|
106
|
+
}
|
package/cli/mcp-proxy.ts
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `forge mcp` — thin stdio↔HTTP proxy for the Forge Management MCP.
|
|
3
|
+
*
|
|
4
|
+
* THE CONTRACT (see mcp/README.md): transport-only. No database driver, no SQL,
|
|
5
|
+
* no `lib/` business imports, no secrets. It reads newline-delimited JSON-RPC
|
|
6
|
+
* frames from stdin, forwards each to the Forge server's /api/mcp endpoint over
|
|
7
|
+
* HTTP, and writes responses back to stdout. All logic lives behind the service.
|
|
8
|
+
*
|
|
9
|
+
* stdout is the protocol channel — diagnostics MUST go to stderr only, and only
|
|
10
|
+
* well-formed JSON-RPC frames are ever written to stdout (never a raw HTTP error
|
|
11
|
+
* body, which would corrupt the client's stream).
|
|
12
|
+
*
|
|
13
|
+
* Robustness (so a real client never hangs or sees garbage):
|
|
14
|
+
* - transport failure / non-2xx → a JSON-RPC error keyed to the frame's id is
|
|
15
|
+
* emitted, so the client's pending request resolves instead of hanging.
|
|
16
|
+
* - a dead session (server restarted → 404) is healed transparently: the cached
|
|
17
|
+
* `initialize` frame is replayed to mint a new session, then the failed frame
|
|
18
|
+
* is retried once.
|
|
19
|
+
* - on stdin EOF the session is released with a best-effort DELETE.
|
|
20
|
+
*
|
|
21
|
+
* Config (env):
|
|
22
|
+
* MW_URL base URL of the Forge server (default http://localhost:8403)
|
|
23
|
+
* FORGE_API_KEY persistent API key (sent as Authorization: Bearer). Generate
|
|
24
|
+
* one in Settings → API Key. Falls back to a Forge-owned
|
|
25
|
+
* ~/.forge/mcp-key file. Preferred over the legacy token below.
|
|
26
|
+
* FORGE_API_TOKEN legacy admin-minted token (sent as x-forge-token). Ephemeral
|
|
27
|
+
* in-memory/session-scoped — mint one via POST /api/auth/verify
|
|
28
|
+
* with the admin password. Prefer FORGE_API_KEY.
|
|
29
|
+
*
|
|
30
|
+
* Session plumbing: the Streamable-HTTP endpoint is stateful, so this captures
|
|
31
|
+
* the Mcp-Session-Id from the initialize response and the negotiated
|
|
32
|
+
* MCP-Protocol-Version, and replays both on later requests.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { createInterface } from 'node:readline';
|
|
36
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
37
|
+
import { homedir } from 'node:os';
|
|
38
|
+
import { join } from 'node:path';
|
|
39
|
+
|
|
40
|
+
/** Resolve the API credential: env first, then a Forge-owned key file
|
|
41
|
+
* (~/.forge/mcp-key). Never read from ~/.claude or other prompt infrastructure. */
|
|
42
|
+
function resolveCredential(): string {
|
|
43
|
+
const env = process.env.FORGE_API_KEY || process.env.FORGE_API_TOKEN;
|
|
44
|
+
if (env) return env.trim();
|
|
45
|
+
try {
|
|
46
|
+
const f = join(homedir(), '.forge', 'mcp-key');
|
|
47
|
+
if (existsSync(f)) return readFileSync(f, 'utf-8').trim();
|
|
48
|
+
} catch { /* ignore */ }
|
|
49
|
+
return '';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Injectable IO + fetch, so the recovery logic can be tested without a real
|
|
53
|
+
* server or stdio. Defaults wire up process.stdin/stdout/stderr and global fetch. */
|
|
54
|
+
export interface ProxyDeps {
|
|
55
|
+
input?: NodeJS.ReadableStream;
|
|
56
|
+
writeOut?: (s: string) => void;
|
|
57
|
+
logErr?: (s: string) => void;
|
|
58
|
+
fetchImpl?: typeof fetch;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function mcpProxyCommand(_args: string[], deps: ProxyDeps = {}): Promise<void> {
|
|
62
|
+
const input = deps.input ?? process.stdin;
|
|
63
|
+
const out = deps.writeOut ?? ((s: string) => { process.stdout.write(s); });
|
|
64
|
+
const logErr = deps.logErr ?? ((s: string) => { process.stderr.write(s); });
|
|
65
|
+
const doFetch = deps.fetchImpl ?? fetch;
|
|
66
|
+
|
|
67
|
+
const base = (process.env.MW_URL || 'http://localhost:8403').replace(/\/$/, '');
|
|
68
|
+
const endpoint = `${base}/api/mcp`;
|
|
69
|
+
const credential = resolveCredential();
|
|
70
|
+
// forge_sk_ keys go in Authorization: Bearer; legacy admin-minted tokens stay
|
|
71
|
+
// on x-forge-token. Applied to every request.
|
|
72
|
+
const applyAuth = (headers: Record<string, string>) => {
|
|
73
|
+
if (!credential) return;
|
|
74
|
+
if (credential.startsWith('forge_sk_')) headers['authorization'] = `Bearer ${credential}`;
|
|
75
|
+
else headers['x-forge-token'] = credential;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const err = (m: string) => logErr(`[forge mcp] ${m}\n`);
|
|
79
|
+
err(`proxy → ${endpoint}`);
|
|
80
|
+
|
|
81
|
+
let sessionId = '';
|
|
82
|
+
let protocolVersion = '';
|
|
83
|
+
let initFrame = ''; // cached initialize request, for transparent session recovery
|
|
84
|
+
|
|
85
|
+
// ── stdout helpers (protocol channel) ──────────────────
|
|
86
|
+
const writeFrame = (text: string) => out(text.endsWith('\n') ? text : text + '\n');
|
|
87
|
+
const emit = (obj: unknown) => out(JSON.stringify(obj) + '\n');
|
|
88
|
+
const rpcError = (id: unknown, message: string, code = -32000) =>
|
|
89
|
+
emit({ jsonrpc: '2.0', id, error: { code, message } });
|
|
90
|
+
|
|
91
|
+
// ── HTTP helpers ───────────────────────────────────────
|
|
92
|
+
const post = (frame: string, useSession: boolean): Promise<Response> => {
|
|
93
|
+
const headers: Record<string, string> = {
|
|
94
|
+
'content-type': 'application/json',
|
|
95
|
+
accept: 'application/json, text/event-stream',
|
|
96
|
+
};
|
|
97
|
+
applyAuth(headers);
|
|
98
|
+
if (useSession && sessionId) headers['mcp-session-id'] = sessionId;
|
|
99
|
+
if (protocolVersion) headers['mcp-protocol-version'] = protocolVersion;
|
|
100
|
+
return doFetch(endpoint, { method: 'POST', headers, body: frame });
|
|
101
|
+
};
|
|
102
|
+
const adoptSession = (res: Response) => {
|
|
103
|
+
const sid = res.headers.get('mcp-session-id');
|
|
104
|
+
if (sid) sessionId = sid;
|
|
105
|
+
};
|
|
106
|
+
const learnProtocol = (text: string) => {
|
|
107
|
+
try {
|
|
108
|
+
const v = JSON.parse(text)?.result?.protocolVersion;
|
|
109
|
+
if (typeof v === 'string') protocolVersion = v;
|
|
110
|
+
} catch { /* not an initialize result — ignore */ }
|
|
111
|
+
};
|
|
112
|
+
// Never surface a raw HTTP body (could be HTML) onto the protocol channel.
|
|
113
|
+
const errorMessage = (status: number, text: string): string => {
|
|
114
|
+
try {
|
|
115
|
+
const m = JSON.parse(text)?.error?.message;
|
|
116
|
+
if (typeof m === 'string') return m;
|
|
117
|
+
} catch { /* not JSON */ }
|
|
118
|
+
return `Forge server returned HTTP ${status}`;
|
|
119
|
+
};
|
|
120
|
+
const sessionDead = (status: number, text: string) =>
|
|
121
|
+
status === 404 || (status === 400 && /not initialized|session not found/i.test(text));
|
|
122
|
+
|
|
123
|
+
/** Replay initialize on a fresh session, then retry the failed frame once.
|
|
124
|
+
* Always "handles" the frame (writes output or a JSON-RPC error). */
|
|
125
|
+
async function recover(frame: string, id: unknown, hasId: boolean): Promise<void> {
|
|
126
|
+
err('session expired — re-initializing');
|
|
127
|
+
sessionId = '';
|
|
128
|
+
protocolVersion = '';
|
|
129
|
+
let initRes: Response;
|
|
130
|
+
try { initRes = await post(initFrame, false); }
|
|
131
|
+
catch (e) { if (hasId) rpcError(id, `forge mcp: re-init failed: ${(e as Error).message}`); return; }
|
|
132
|
+
adoptSession(initRes);
|
|
133
|
+
if (!initRes.ok) { if (hasId) rpcError(id, 'forge mcp: re-initialization failed'); return; }
|
|
134
|
+
learnProtocol(await initRes.text());
|
|
135
|
+
try { await post('{"jsonrpc":"2.0","method":"notifications/initialized"}', true); } catch { /* best-effort */ }
|
|
136
|
+
|
|
137
|
+
let retry: Response;
|
|
138
|
+
try { retry = await post(frame, true); }
|
|
139
|
+
catch (e) { if (hasId) rpcError(id, `forge mcp: ${(e as Error).message}`); return; }
|
|
140
|
+
adoptSession(retry);
|
|
141
|
+
if (retry.status === 202) return;
|
|
142
|
+
const rtext = await retry.text();
|
|
143
|
+
if (retry.ok) { if (rtext) writeFrame(rtext); return; }
|
|
144
|
+
if (hasId) rpcError(id, errorMessage(retry.status, rtext));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── main loop ──────────────────────────────────────────
|
|
148
|
+
const rl = createInterface({ input, crlfDelay: Infinity });
|
|
149
|
+
|
|
150
|
+
for await (const line of rl) {
|
|
151
|
+
const frame = line.trim();
|
|
152
|
+
if (!frame) continue;
|
|
153
|
+
|
|
154
|
+
let msg: { id?: unknown; method?: string } | undefined;
|
|
155
|
+
try { msg = JSON.parse(frame); }
|
|
156
|
+
catch { err('dropping non-JSON stdin line'); continue; }
|
|
157
|
+
const hasId = !!msg && 'id' in msg && msg.id !== null && msg.id !== undefined;
|
|
158
|
+
const id = hasId ? msg!.id : undefined;
|
|
159
|
+
if (msg?.method === 'initialize') initFrame = frame;
|
|
160
|
+
|
|
161
|
+
let res: Response;
|
|
162
|
+
try { res = await post(frame, true); }
|
|
163
|
+
catch (e) {
|
|
164
|
+
err(`request failed: ${(e as Error).message}`);
|
|
165
|
+
if (hasId) rpcError(id, `forge mcp: ${(e as Error).message}`);
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
adoptSession(res);
|
|
170
|
+
if (res.status === 202) continue; // notification/response accepted, no body
|
|
171
|
+
|
|
172
|
+
const text = await res.text();
|
|
173
|
+
if (res.ok) {
|
|
174
|
+
learnProtocol(text);
|
|
175
|
+
if (text) writeFrame(text);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (sessionDead(res.status, text) && initFrame && msg?.method !== 'initialize') {
|
|
180
|
+
await recover(frame, id, hasId);
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (hasId) rpcError(id, errorMessage(res.status, text));
|
|
184
|
+
else err(`server ${res.status} on notification (dropped)`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// stdin closed → release the server-side session (best-effort).
|
|
188
|
+
if (sessionId) {
|
|
189
|
+
try {
|
|
190
|
+
const headers: Record<string, string> = { 'mcp-session-id': sessionId };
|
|
191
|
+
applyAuth(headers);
|
|
192
|
+
if (protocolVersion) headers['mcp-protocol-version'] = protocolVersion;
|
|
193
|
+
await doFetch(endpoint, { method: 'DELETE', headers });
|
|
194
|
+
} catch { /* best-effort */ }
|
|
195
|
+
}
|
|
196
|
+
}
|