Skip to content

Commit 95de899

Browse files
authored
feat(cli): unity-mcp-cli → @baizor/gamedev-cli-core (OAuth login, pinned setup-mcp, optional install-plugin path) (#907)
Migrated unity-mcp-cli to a thin wrapper over @baizor/gamedev-cli-core: OAuth device login (T1), pinned setup-mcp URL (T4), optional install-plugin path (T5), v2 enroll identity; legacy /api/auth/device PAT flow removed. Closes #906
1 parent fcb81cd commit 95de899

20 files changed

Lines changed: 631 additions & 1912 deletions

cli/package-lock.json

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cli/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
"node": "^20.19.0 || >=22.12.0"
6363
},
6464
"dependencies": {
65+
"@baizor/gamedev-cli-core": "^0.1.0",
6566
"chalk": "^5.6.2",
6667
"commander": "^13.1.0",
6768
"yocto-spinner": "^1.1.0"
@@ -71,4 +72,4 @@
7172
"typescript": "^5.8.0",
7273
"vitest": "^3.1.0"
7374
}
74-
}
75+
}

cli/src/commands/install-plugin.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import { Command } from 'commander';
22
import * as fs from 'fs';
3-
import * as path from 'path';
43
import * as ui from '../utils/ui.js';
54
import { verbose } from '../utils/ui.js';
65
import { installPlugin } from '../lib/install-plugin.js';
76
import { downloadServerBinary } from '../utils/managed-server.js';
87
import { DEFAULT_SERVER_VERSION } from '../utils/server-version.js';
98
import { resolveEnrollCode, runEnroll, EnrollmentError } from '../utils/enroll.js';
109
import { MachineCredentialStore } from '../utils/machine-credentials.js';
10+
import { resolveInstallTarget, unityAdapter } from '@baizor/gamedev-cli-core';
1111

1212
interface InstallPluginOptions {
1313
path?: string;
@@ -30,13 +30,20 @@ export const installPluginCommand = new Command('install-plugin')
3030
.option('--enroll <code>', 'Redeem an enrollment code for a plugin credential (planted in the shared machine store)')
3131
.option('--enroll-stdin', 'Read the enrollment code from stdin (never argv/shell history)')
3232
.action(async (positionalPath: string | undefined, options: InstallPluginOptions) => {
33-
const resolvedPath = positionalPath ?? options.path;
34-
if (!resolvedPath) {
35-
ui.error('Path is required. Usage: unity-mcp-cli install-plugin <path> or --path <path>');
33+
// T5/B1: path is OPTIONAL — resolve `path? → --path? → cwd`, then verify the directory is a real
34+
// Unity project (marker probe for `Packages/manifest.json`). On a miss the error lists exactly
35+
// what was checked, so `install-plugin` "just works" from a project folder with no path.
36+
const target = resolveInstallTarget({
37+
adapter: unityAdapter,
38+
positional: positionalPath,
39+
path: options.path,
40+
});
41+
if (target.kind === 'failure') {
42+
ui.error(target.error.message);
3643
process.exit(1);
3744
}
3845

39-
const projectPath = path.resolve(resolvedPath);
46+
const projectPath = target.projectRoot;
4047

4148
// ── Phase 1: install the plugin into the Unity project's manifest ────────
4249
// Wire the library's progress events back into the CLI's chalk-
@@ -128,6 +135,7 @@ export const installPluginCommand = new Command('install-plugin')
128135
const enrolled = await runEnroll({
129136
code,
130137
projectPath,
138+
adapter: unityAdapter,
131139
store: new MachineCredentialStore(),
132140
});
133141
enrollSpinner.success('Enrollment complete');

cli/src/commands/setup-mcp.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ interface SetupMcpCliOptions {
1515
url?: string;
1616
token?: string;
1717
list?: boolean;
18+
/** commander sets this to `false` when `--no-pin` is passed (defaults to `true`). */
19+
pin?: boolean;
1820
}
1921

2022
function listAgents(): void {
@@ -31,7 +33,8 @@ export const setupMcpCommand = new Command('setup-mcp')
3133
'http',
3234
)
3335
.option('--url <url>', 'Server URL override (for http transport)')
34-
.option('--token <token>', 'Auth token override')
36+
.option('--token <token>', 'Explicit PAT opt-in — writes a static credential into the config (default: credential-free, native OAuth)')
37+
.option('--no-pin', 'Write an unpinned URL / omit the project= arg (default: pin to this project via /mcp/p/<pin>)')
3538
.option('--list', 'List all available agent IDs')
3639
.action(
3740
async (
@@ -74,6 +77,8 @@ export const setupMcpCommand = new Command('setup-mcp')
7477
transport,
7578
url: options.url,
7679
token: options.token,
80+
// commander sets `options.pin === false` when `--no-pin` was passed.
81+
noPin: options.pin === false,
7782
});
7883

7984
if (result.kind === 'failure') {

cli/src/lib/setup-mcp.ts

Lines changed: 64 additions & 167 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,10 @@
11
import * as fs from 'fs';
22
import * as path from 'path';
3-
import { generatePortFromDirectory } from '../utils/port.js';
4-
import { readConfig, resolveConnectionFromConfig } from '../utils/config.js';
53
import {
6-
getAgentById,
4+
setupMcp as coreSetupMcp,
5+
unityAdapter,
76
getAgentIds,
8-
resolveServerBinaryPath,
9-
writeJsonAgentConfig,
10-
writeTomlAgentConfig,
11-
MCP_SERVER_NAME,
12-
} from '../utils/agents.js';
13-
import { emitProgress } from './progress.js';
7+
} from '@baizor/gamedev-cli-core';
148
import { requireExistingPath } from './validation.js';
159
import type {
1610
SetupMcpOptions,
@@ -23,199 +17,102 @@ function isValidTransport(t: string | undefined): t is McpTransport {
2317
}
2418

2519
/**
26-
* Extract a port from a `host` URL string, falling back to the
27-
* project-directory-derived deterministic port on any parse failure.
28-
*/
29-
function portFromHost(host: string | undefined, projectPath: string): number {
30-
if (!host) return generatePortFromDirectory(projectPath);
31-
try {
32-
return parseInt(new URL(host).port, 10) || generatePortFromDirectory(projectPath);
33-
} catch {
34-
return generatePortFromDirectory(projectPath);
35-
}
36-
}
37-
38-
/**
39-
* True when `configPath` resolves to a location at or under `projectRoot`
40-
* (separator- and case-insensitive) — i.e. a VCS-visible project-scoped file.
41-
* Mirrors the b6 configurator's `IsProjectScopedPath`, used to decide whether a
42-
* written access token would land in a committable file (design 03 Flow C).
43-
*/
44-
function isProjectScoped(configPath: string, projectRoot: string): boolean {
45-
const norm = (p: string): string =>
46-
path.resolve(p).replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
47-
const root = norm(projectRoot);
48-
const target = norm(configPath);
49-
return target === root || target.startsWith(root + '/');
50-
}
51-
52-
/**
53-
* Write MCP configuration for the given AI agent, so it can talk to
54-
* Unity-MCP. Library-safe: no stdout noise, no process.exit, no throws
55-
* past the public boundary.
20+
* Write MCP configuration for the given AI agent, so it can talk to the Unity MCP hub.
5621
*
57-
* The returned `SetupMcpResult` is a discriminated union — narrow with
58-
* `result.kind === 'success'` to access `agentId` / `configPath` /
59-
* `transport`, or `result.kind === 'failure'` to access `error`.
22+
* This is now a thin adapter over `@baizor/gamedev-cli-core`'s `setupMcp` (auth-fixes T4/M7/M8):
23+
* - the http URL is **pinned by default** to `<base>/mcp/p/<pin-v2>` and the stdio config carries a
24+
* `project=<pin>` arg, so the config routes strictly to this project's engine instance even when
25+
* the account has several (`--no-pin` is the escape hatch);
26+
* - the config is **credential-free by default** — a static `Authorization: Bearer` header (http)
27+
* or `token=` arg (stdio) is written ONLY on an explicit `--token` PAT opt-in, so an OAuth-capable
28+
* client authorizes natively (RFC 9728) instead of being suppressed by a static header;
29+
* - the config bytes are written by cli-core's golden-vector-gated JSON/TOML writers, so `setup-mcp`
30+
* output matches the Unity Editor's Configure output byte-for-byte.
31+
*
32+
* Library-safe: no stdout noise, no `process.exit`, no throws past the public boundary. Narrow the
33+
* returned `SetupMcpResult` with `result.kind === 'success'` / `=== 'failure'`.
6034
*/
6135
export async function setupMcp(opts: SetupMcpOptions): Promise<SetupMcpResult> {
62-
const warnings: string[] = [];
63-
const nextSteps: string[] = [];
64-
36+
// This wrapper never accumulates its own warnings/next-steps — cli-core owns the warning channel
37+
// (`result.warnings`), and `nextSteps` is always empty here. Both fields are written as literals
38+
// on each return to satisfy the `SetupMcpResult` shape.
6539
try {
66-
if (!opts || typeof opts.agentId !== 'string' || opts.agentId.length === 0) {
67-
return {
68-
kind: 'failure',
69-
success: false,
70-
warnings,
71-
nextSteps,
72-
error: new Error(
73-
`agentId is required. Available agent IDs: ${getAgentIds().join(', ')}`,
74-
),
75-
};
40+
// Resolve project path. If the caller supplied one explicitly it must exist; otherwise cli-core
41+
// falls back to cwd (matching the CLI behaviour and closing the "path required" half of B1).
42+
let projectPath: string | undefined;
43+
if (opts.unityProjectPath) {
44+
const validated = requireExistingPath(opts.unityProjectPath);
45+
if (!validated.ok) {
46+
return { kind: 'failure', success: false, warnings: [], nextSteps: [], error: validated.error };
47+
}
48+
projectPath = validated.projectPath;
7649
}
7750

78-
const agent = getAgentById(opts.agentId);
79-
if (!agent) {
51+
const transport = opts.transport ?? 'http';
52+
if (!isValidTransport(transport)) {
8053
return {
8154
kind: 'failure',
8255
success: false,
83-
warnings,
84-
nextSteps,
85-
error: new Error(
86-
`Unknown agent: "${opts.agentId}". Available agent IDs: ${getAgentIds().join(', ')}`,
87-
),
56+
warnings: [],
57+
nextSteps: [],
58+
error: new Error(`Invalid transport: "${transport}". Must be "stdio" or "http".`),
8859
};
8960
}
9061

91-
const transport: McpTransport = opts.transport ?? 'http';
92-
if (!isValidTransport(transport)) {
62+
const result = coreSetupMcp({
63+
adapter: unityAdapter,
64+
agentId: opts.agentId,
65+
transport,
66+
projectPath,
67+
url: opts.url,
68+
token: opts.token,
69+
noPin: opts.noPin === true,
70+
});
71+
72+
if (result.kind === 'failure') {
9373
return {
9474
kind: 'failure',
9575
success: false,
96-
warnings,
97-
nextSteps,
98-
error: new Error(`Invalid transport: "${transport}". Must be "stdio" or "http".`),
76+
warnings: result.warnings,
77+
nextSteps: [],
78+
error: result.error,
9979
};
10080
}
10181

102-
// Resolve project path. If the caller supplied one explicitly it
103-
// must exist; otherwise fall back to cwd (matches CLI behaviour).
104-
let projectPath: string;
105-
if (opts.unityProjectPath) {
106-
const validated = requireExistingPath(opts.unityProjectPath);
107-
if (!validated.ok) {
108-
return { kind: 'failure', success: false, warnings, nextSteps, error: validated.error };
109-
}
110-
projectPath = validated.projectPath;
111-
} else {
112-
projectPath = path.resolve(process.cwd());
113-
}
114-
115-
emitProgress(opts.onProgress, {
116-
phase: 'start',
117-
message: `Configuring ${agent.name} (${transport}) for ${projectPath}`,
118-
});
119-
120-
const config = readConfig(projectPath);
121-
const fromConfig = config
122-
? resolveConnectionFromConfig(config)
123-
: { url: undefined, token: undefined };
124-
125-
const port = portFromHost(config?.host, projectPath);
126-
127-
const timeout = (config?.timeoutMs as number) ?? 10000;
128-
const auth = (config?.authOption as string) ?? 'none';
129-
130-
// Explicit PAT opt-in (design 03 Flow C): a token the caller passed on the
131-
// command line (`--token`). Only an explicit opt-in — never a token merely
132-
// resolved from the project config — places a static credential in the file.
133-
const patOptIn = typeof opts.token === 'string' && opts.token.length > 0;
134-
const token = opts.token ?? fromConfig.token ?? '';
135-
136-
// b6 credential policy (design decision D11): the DEFAULT http config is
137-
// credential-free. OAuth-capable clients perform native RFC 9728 OAuth
138-
// against the URL (Flow A), so a static `Authorization: Bearer` header both
139-
// FAILS (the hosted ai-game.dev/mcp endpoint 401s the token) AND suppresses
140-
// the client's own OAuth handshake ("OAuth fallback is disabled when
141-
// headers.Authorization is set"). A static header is written ONLY for an
142-
// explicit PAT opt-in (Flow C) or a client that cannot do MCP OAuth
143-
// (`supportsOAuth === false`) — never automatically for an OAuth-capable
144-
// client, regardless of the server's `authRequired` setting.
145-
const supportsOAuth = agent.supportsOAuth !== false;
146-
const emitAuthHeader = token.length > 0 && (patOptIn || !supportsOAuth);
147-
148-
const serverPath = resolveServerBinaryPath(projectPath).replace(/\\/g, '/');
149-
150-
// Resolve URL for HTTP — explicit override, then config, then
151-
// deterministic localhost fallback. Trailing slash stripped.
152-
const serverUrl = (opts.url ?? fromConfig.url ?? `http://localhost:${port}`).replace(/\/$/, '');
153-
154-
const configPath = agent.getConfigPath(projectPath);
155-
156-
let props: Record<string, unknown>;
157-
let removeKeys: string[];
158-
82+
// Preserve the historical stdio-server-missing warning (cli-core does not emit it): the plugin
83+
// downloads the server binary when Unity opens, so a stdio config written before that is valid
84+
// but not yet runnable.
15985
if (transport === 'stdio') {
160-
props = agent.getStdioProps(serverPath, port, timeout, auth, token);
161-
removeKeys = agent.stdioRemoveKeys;
162-
} else {
163-
props = agent.getHttpProps(serverUrl, token, emitAuthHeader);
164-
removeKeys = agent.httpRemoveKeys;
165-
}
166-
167-
if (agent.configFormat === 'toml') {
168-
writeTomlAgentConfig(configPath, agent.bodyPath, MCP_SERVER_NAME, props, removeKeys);
169-
} else {
170-
writeJsonAgentConfig(configPath, agent.bodyPath, MCP_SERVER_NAME, props, removeKeys);
171-
}
172-
173-
emitProgress(opts.onProgress, {
174-
phase: 'manifest-patched',
175-
message: `Wrote ${configPath}`,
176-
manifestPath: configPath,
177-
});
178-
179-
// Flow C credential-placement rule (design 03): a static access token
180-
// written into a project-scoped config file is VCS-visible. Warn so the
181-
// user prefers an env-var / user-scope placement for the credential.
182-
if (transport === 'http' && emitAuthHeader && isProjectScoped(configPath, projectPath)) {
183-
warnings.push(
184-
`Wrote an access token into project-scoped config file "${configPath}" — it is under the project root and may be committed to version control. Prefer an env-var or user-scope placement for the access token (design 03 Flow C).`,
185-
);
186-
}
187-
188-
if (transport === 'stdio' && !fs.existsSync(serverPath.replace(/\//g, path.sep))) {
189-
warnings.push(
190-
'Server binary not found. Open Unity with the MCP plugin to download it automatically.',
191-
);
86+
const serverPath = unityAdapter.serverBinaryPath(projectPath ?? path.resolve(process.cwd()));
87+
if (!fs.existsSync(serverPath)) {
88+
result.warnings.push(
89+
'Server binary not found. Open Unity with the MCP plugin to download it automatically.',
90+
);
91+
}
19292
}
19393

194-
emitProgress(opts.onProgress, { phase: 'done', message: `${agent.name} configured successfully.` });
195-
19694
return {
19795
kind: 'success',
19896
success: true,
199-
agentId: agent.id,
200-
configPath,
201-
transport,
202-
warnings,
203-
nextSteps,
97+
agentId: result.agentId,
98+
configPath: result.configPath,
99+
transport: result.transport,
100+
warnings: result.warnings,
101+
nextSteps: [],
204102
};
205103
} catch (err: unknown) {
206104
return {
207105
kind: 'failure',
208106
success: false,
209-
warnings,
210-
nextSteps,
107+
warnings: [],
108+
nextSteps: [],
211109
error: err instanceof Error ? err : new Error(String(err)),
212110
};
213111
}
214112
}
215113

216114
/**
217-
* List every agent id known to the `setupMcp` function. Useful for
218-
* consumer UIs that want to render a picker.
115+
* List every agent id known to `setupMcp`. Useful for consumer UIs that want to render a picker.
219116
*/
220117
export function listAgentIds(): string[] {
221118
return getAgentIds();

0 commit comments

Comments
 (0)