mcp-yoto 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Thin wrapper around Yoto's `POST /oauth/token` endpoint, shared by both
3
+ * legs that call it: the authorization-code exchange (adapter.ts, once per
4
+ * sign-in) and the refresh-token grant (session.ts, on every access-token
5
+ * renewal). Kept deliberately dumb -- it does not interpret the response,
6
+ * just forwards it -- so the two callers can apply their own (different)
7
+ * error handling.
8
+ */
9
+ export interface TokenResponse {
10
+ access_token: string;
11
+ refresh_token?: string;
12
+ expires_in?: number;
13
+ scope?: string;
14
+ }
15
+ export interface TokenErrorBody {
16
+ error?: string;
17
+ error_description?: string;
18
+ }
19
+ export type TokenResult = {
20
+ ok: true;
21
+ data: TokenResponse;
22
+ } | {
23
+ ok: false;
24
+ status: number;
25
+ error?: TokenErrorBody;
26
+ };
27
+ export declare function postTokenRequest(fetchImpl: typeof fetch, tokenUrl: string, params: Record<string, string>): Promise<TokenResult>;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Thin wrapper around Yoto's `POST /oauth/token` endpoint, shared by both
3
+ * legs that call it: the authorization-code exchange (adapter.ts, once per
4
+ * sign-in) and the refresh-token grant (session.ts, on every access-token
5
+ * renewal). Kept deliberately dumb -- it does not interpret the response,
6
+ * just forwards it -- so the two callers can apply their own (different)
7
+ * error handling.
8
+ */
9
+ export async function postTokenRequest(fetchImpl, tokenUrl, params) {
10
+ const response = await fetchImpl(tokenUrl, {
11
+ method: "POST",
12
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
13
+ body: new URLSearchParams(params),
14
+ });
15
+ if (response.ok) {
16
+ return { ok: true, data: (await response.json()) };
17
+ }
18
+ let error;
19
+ try {
20
+ error = (await response.json());
21
+ }
22
+ catch {
23
+ // Non-JSON error body -- leave `error` undefined, callers fall back to the status code alone.
24
+ }
25
+ return { ok: false, status: response.status, error };
26
+ }
@@ -0,0 +1,69 @@
1
+ import type { Logger } from "@mcp-yoto/core";
2
+ export interface StoredTokens {
3
+ accessToken: string;
4
+ refreshToken?: string;
5
+ /** Unix milliseconds. Belt-and-braces alongside the JWT's own `exp` claim -- see session.ts. */
6
+ expiresAt?: number;
7
+ /** Space-separated scopes Yoto actually granted. */
8
+ scope?: string;
9
+ }
10
+ export interface TokenStore {
11
+ readonly kind: "keychain" | "file";
12
+ load(): Promise<StoredTokens | undefined>;
13
+ save(tokens: StoredTokens): Promise<void>;
14
+ clear(): Promise<void>;
15
+ }
16
+ /**
17
+ * OS keychain-backed store (Windows Credential Manager / macOS Keychain /
18
+ * Secret Service on Linux) via `@napi-rs/keyring`.
19
+ *
20
+ * Imports the native module dynamically and lazily -- it's an
21
+ * `optionalDependency` (see apps/cli/package.json), so a platform with no
22
+ * prebuilt binding must still be able to `npm install` and run; the failure
23
+ * surfaces here, at first use, and `createTokenStore()` catches it.
24
+ */
25
+ export declare class KeyringStore implements TokenStore {
26
+ private readonly account;
27
+ readonly kind: "keychain";
28
+ constructor(account: string);
29
+ private entry;
30
+ load(): Promise<StoredTokens | undefined>;
31
+ save(tokens: StoredTokens): Promise<void>;
32
+ clear(): Promise<void>;
33
+ }
34
+ /** `~/.config/mcp-yoto/tokens.json`, or on Windows `%APPDATA%\mcp-yoto\tokens.json`. */
35
+ export declare function defaultTokenFilePath(): string;
36
+ /**
37
+ * Plain-file fallback: crash-safe temp+rename writes with a `.last-good`
38
+ * backup (ported from the old yoto-mcp-server's config store), 0600
39
+ * permissions on POSIX (Windows ACLs already scope the file to the current
40
+ * user's profile), and BOM-tolerant reads (a PowerShell-written file often
41
+ * carries a UTF-8 BOM, which breaks a naive `JSON.parse`).
42
+ */
43
+ export declare class FileStore implements TokenStore {
44
+ readonly kind: "file";
45
+ private readonly filePath;
46
+ constructor(filePath?: string);
47
+ load(): Promise<StoredTokens | undefined>;
48
+ private readFrom;
49
+ save(tokens: StoredTokens): Promise<void>;
50
+ clear(): Promise<void>;
51
+ }
52
+ export interface CreateTokenStoreOptions {
53
+ /** Keychain account name -- the client id, per the plan's "service mcp-yoto, account = client id". */
54
+ account: string;
55
+ logger: Logger;
56
+ /** Forces a `FileStore` at this exact path, skipping the keychain entirely. See config.ts's `tokenFileOverride`. */
57
+ fileOverride?: string;
58
+ }
59
+ /**
60
+ * Picks the token store for this run: the OS keychain when it works, a
61
+ * plain file (with one loud stderr warning, surfaced again in
62
+ * `yoto_status`) when it doesn't.
63
+ *
64
+ * The probe is `load()` itself: on a fresh machine with nothing stored yet,
65
+ * `getPassword()` resolves to `undefined` rather than throwing (see
66
+ * @napi-rs/keyring's own contract), so an empty-but-working keychain is
67
+ * correctly told apart from a broken one.
68
+ */
69
+ export declare function createTokenStore(options: CreateTokenStoreOptions): Promise<TokenStore>;
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Where the CLI's Yoto credential lives at rest: the OS keychain by
3
+ * preference (`KeyringStore`, via `@napi-rs/keyring`), falling back to a
4
+ * plain file (`FileStore`) when the keychain is unusable (no native
5
+ * binding for this platform, a headless box with no keyring daemon, a
6
+ * locked session). `createTokenStore()` is the one entry point that makes
7
+ * that choice; everything else in this file is a plain, directly testable
8
+ * `TokenStore` implementation.
9
+ */
10
+ import { chmod, copyFile, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
11
+ import { homedir } from "node:os";
12
+ import { dirname, join } from "node:path";
13
+ const SERVICE = "mcp-yoto";
14
+ /**
15
+ * OS keychain-backed store (Windows Credential Manager / macOS Keychain /
16
+ * Secret Service on Linux) via `@napi-rs/keyring`.
17
+ *
18
+ * Imports the native module dynamically and lazily -- it's an
19
+ * `optionalDependency` (see apps/cli/package.json), so a platform with no
20
+ * prebuilt binding must still be able to `npm install` and run; the failure
21
+ * surfaces here, at first use, and `createTokenStore()` catches it.
22
+ */
23
+ export class KeyringStore {
24
+ account;
25
+ kind = "keychain";
26
+ constructor(account) {
27
+ this.account = account;
28
+ }
29
+ async entry() {
30
+ const { AsyncEntry } = await import("@napi-rs/keyring");
31
+ return new AsyncEntry(SERVICE, this.account);
32
+ }
33
+ async load() {
34
+ const entry = await this.entry();
35
+ const raw = await entry.getPassword();
36
+ if (!raw)
37
+ return undefined;
38
+ try {
39
+ return JSON.parse(raw);
40
+ }
41
+ catch {
42
+ return undefined;
43
+ }
44
+ }
45
+ async save(tokens) {
46
+ const entry = await this.entry();
47
+ await entry.setPassword(JSON.stringify(tokens));
48
+ }
49
+ async clear() {
50
+ const entry = await this.entry();
51
+ await entry.deletePassword();
52
+ }
53
+ }
54
+ /** `~/.config/mcp-yoto/tokens.json`, or on Windows `%APPDATA%\mcp-yoto\tokens.json`. */
55
+ export function defaultTokenFilePath() {
56
+ if (process.platform === "win32") {
57
+ const appData = process.env.APPDATA || join(homedir(), "AppData", "Roaming");
58
+ return join(appData, "mcp-yoto", "tokens.json");
59
+ }
60
+ return join(homedir(), ".config", "mcp-yoto", "tokens.json");
61
+ }
62
+ /**
63
+ * Plain-file fallback: crash-safe temp+rename writes with a `.last-good`
64
+ * backup (ported from the old yoto-mcp-server's config store), 0600
65
+ * permissions on POSIX (Windows ACLs already scope the file to the current
66
+ * user's profile), and BOM-tolerant reads (a PowerShell-written file often
67
+ * carries a UTF-8 BOM, which breaks a naive `JSON.parse`).
68
+ */
69
+ export class FileStore {
70
+ kind = "file";
71
+ filePath;
72
+ constructor(filePath = defaultTokenFilePath()) {
73
+ this.filePath = filePath;
74
+ }
75
+ async load() {
76
+ const primary = await this.readFrom(this.filePath);
77
+ if (primary)
78
+ return primary;
79
+ // The main file is missing or corrupt (e.g. a crash mid-write left a
80
+ // truncated .tmp that never got renamed, or a partial write slipped
81
+ // through) -- recover from the last file we know was ever written cleanly.
82
+ return this.readFrom(`${this.filePath}.last-good`);
83
+ }
84
+ async readFrom(path) {
85
+ let raw;
86
+ try {
87
+ raw = await readFile(path, "utf-8");
88
+ }
89
+ catch {
90
+ return undefined;
91
+ }
92
+ const stripped = raw.replace(/^/, "");
93
+ try {
94
+ return JSON.parse(stripped);
95
+ }
96
+ catch {
97
+ return undefined;
98
+ }
99
+ }
100
+ async save(tokens) {
101
+ await mkdir(dirname(this.filePath), { recursive: true });
102
+ try {
103
+ await copyFile(this.filePath, `${this.filePath}.last-good`);
104
+ }
105
+ catch {
106
+ // No existing file yet -- this is the first sign-in.
107
+ }
108
+ const tmp = `${this.filePath}.tmp`;
109
+ await writeFile(tmp, JSON.stringify(tokens, null, 2), "utf-8");
110
+ await rename(tmp, this.filePath);
111
+ if (process.platform !== "win32") {
112
+ await chmod(this.filePath, 0o600);
113
+ }
114
+ }
115
+ async clear() {
116
+ for (const path of [this.filePath, `${this.filePath}.last-good`, `${this.filePath}.tmp`]) {
117
+ try {
118
+ await rm(path);
119
+ }
120
+ catch {
121
+ // Already gone -- clearing an already-signed-out store is not an error.
122
+ }
123
+ }
124
+ }
125
+ }
126
+ /**
127
+ * Picks the token store for this run: the OS keychain when it works, a
128
+ * plain file (with one loud stderr warning, surfaced again in
129
+ * `yoto_status`) when it doesn't.
130
+ *
131
+ * The probe is `load()` itself: on a fresh machine with nothing stored yet,
132
+ * `getPassword()` resolves to `undefined` rather than throwing (see
133
+ * @napi-rs/keyring's own contract), so an empty-but-working keychain is
134
+ * correctly told apart from a broken one.
135
+ */
136
+ export async function createTokenStore(options) {
137
+ if (options.fileOverride) {
138
+ return new FileStore(options.fileOverride);
139
+ }
140
+ try {
141
+ const keyring = new KeyringStore(options.account);
142
+ await keyring.load();
143
+ return keyring;
144
+ }
145
+ catch (error) {
146
+ const filePath = defaultTokenFilePath();
147
+ options.logger.warn(`System keychain unavailable -- falling back to a local file at ${filePath} ` +
148
+ "(0600 permissions on macOS/Linux; scoped to your Windows user profile).", { error: error instanceof Error ? error.message : String(error) });
149
+ return new FileStore(filePath);
150
+ }
151
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Yoto's public PKCE client for the CLI's existing dev app -- safe to ship
3
+ * in source (no client secret; PKCE is the confidentiality mechanism).
4
+ * Overridable via YOTO_CLIENT_ID for anyone running their own registered
5
+ * Yoto dev app.
6
+ */
7
+ export declare const DEFAULT_CLIENT_ID = "xl4YsMpHEMnn7ubVhsRu8NhBwfUFPf8J";
8
+ export declare const YOTO_AUTH_BASE = "https://login.yotoplay.com";
9
+ export declare const AUDIENCE = "https://api.yotoplay.com";
10
+ export declare const DEFAULT_REDIRECT_PORT = 8791;
11
+ export declare const DEFAULT_ICON_BASE_URL = "https://raw.githubusercontent.com/danpillay87/mcp-yoto/main/apps/worker/public/icons";
12
+ export type LogLevel = "debug" | "info" | "warn" | "error";
13
+ export interface CliConfig {
14
+ clientId: string;
15
+ authBase: string;
16
+ audience: string;
17
+ /** Loopback port for PKCE sign-in. Must match the redirect URI registered at Yoto. */
18
+ redirectPort: number;
19
+ /** True when YOTO_NO_BROWSER is set -- print the sign-in URL instead of opening a browser. */
20
+ noBrowser: boolean;
21
+ logLevel: LogLevel;
22
+ iconBaseUrl: string;
23
+ /** Space-joined scopes this connection ever requests. Never devices:control/manage. */
24
+ scope: string;
25
+ /**
26
+ * Advanced/testing override: forces the token store to a plain file at this
27
+ * path, skipping the OS keychain entirely. Used by the stdio-purity test to
28
+ * sandbox credential storage in a temp directory without touching a real
29
+ * keychain or `~/.config/mcp-yoto`. Not documented as a normal user-facing
30
+ * knob (see README's env var table), but harmless to set by hand too.
31
+ */
32
+ tokenFileOverride?: string;
33
+ }
34
+ /** Reads and validates configuration from `env` (defaults to `process.env`). */
35
+ export declare function loadConfig(env?: NodeJS.ProcessEnv): CliConfig;
package/dist/config.js ADDED
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Configuration for the `npx mcp-yoto` CLI, read from `process.env`.
3
+ *
4
+ * This is the ONE place in apps/cli that touches `process.env` directly --
5
+ * every other module takes an explicit value (a `CliConfig`, a clock, a
6
+ * `fetchImpl`) so it stays trivially testable without mutating globals.
7
+ *
8
+ * Mirrors apps/worker's config.ts decisions (same Yoto auth base, same
9
+ * scope list) but is CLI-only: no KV, no OAuthProvider, no `Env` binding.
10
+ */
11
+ import { YOTO_SCOPES } from "@mcp-yoto/core";
12
+ /**
13
+ * Yoto's public PKCE client for the CLI's existing dev app -- safe to ship
14
+ * in source (no client secret; PKCE is the confidentiality mechanism).
15
+ * Overridable via YOTO_CLIENT_ID for anyone running their own registered
16
+ * Yoto dev app.
17
+ */
18
+ export const DEFAULT_CLIENT_ID = "xl4YsMpHEMnn7ubVhsRu8NhBwfUFPf8J";
19
+ export const YOTO_AUTH_BASE = "https://login.yotoplay.com";
20
+ export const AUDIENCE = "https://api.yotoplay.com";
21
+ export const DEFAULT_REDIRECT_PORT = 8791;
22
+ export const DEFAULT_ICON_BASE_URL = "https://raw.githubusercontent.com/danpillay87/mcp-yoto/main/apps/worker/public/icons";
23
+ function parsePort(raw, fallback) {
24
+ if (!raw)
25
+ return fallback;
26
+ const parsed = Number(raw);
27
+ if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) {
28
+ throw new Error(`YOTO_REDIRECT_PORT must be an integer between 1 and 65535, got ${JSON.stringify(raw)}.`);
29
+ }
30
+ return parsed;
31
+ }
32
+ function parseLogLevel(raw) {
33
+ return raw === "debug" || raw === "warn" || raw === "error" ? raw : "info";
34
+ }
35
+ function parseBoolFlag(raw) {
36
+ return raw === "1" || raw?.toLowerCase() === "true";
37
+ }
38
+ /** Reads and validates configuration from `env` (defaults to `process.env`). */
39
+ export function loadConfig(env = process.env) {
40
+ return {
41
+ clientId: env.YOTO_CLIENT_ID || DEFAULT_CLIENT_ID,
42
+ authBase: YOTO_AUTH_BASE,
43
+ audience: AUDIENCE,
44
+ redirectPort: parsePort(env.YOTO_REDIRECT_PORT, DEFAULT_REDIRECT_PORT),
45
+ noBrowser: parseBoolFlag(env.YOTO_NO_BROWSER),
46
+ logLevel: parseLogLevel(env.LOG_LEVEL),
47
+ iconBaseUrl: env.MCP_YOTO_ICON_BASE || DEFAULT_ICON_BASE_URL,
48
+ scope: YOTO_SCOPES.join(" "),
49
+ tokenFileOverride: env.MCP_YOTO_TOKEN_FILE || undefined,
50
+ };
51
+ }
package/dist/main.d.ts ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * `npx mcp-yoto` entrypoint (invoked via bin/mcp-yoto.mjs).
3
+ *
4
+ * stdout is reserved for JSON-RPC frames when running as an MCP server
5
+ * (the default, no-subcommand mode) -- every diagnostic goes to stderr.
6
+ * `status` is the one deliberate exception: it prints its `AuthStatus` JSON
7
+ * to stdout, because it's meant to be piped/parsed by a human or script
8
+ * checking sign-in state, not read by an MCP client. See test/stdio-purity.test.ts.
9
+ */
10
+ import { DEFAULT_CLIENT_ID } from "./config.js";
11
+ export { DEFAULT_CLIENT_ID };
12
+ export declare function main(argv?: string[]): Promise<void>;