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.
- package/LICENSE +23 -0
- package/README.md +86 -0
- package/bin/mcp-yoto.mjs +4 -0
- package/dist/auth/adapter.d.ts +33 -0
- package/dist/auth/adapter.js +131 -0
- package/dist/auth/loopback.d.ts +28 -0
- package/dist/auth/loopback.js +103 -0
- package/dist/auth/open-browser.d.ts +1 -0
- package/dist/auth/open-browser.js +43 -0
- package/dist/auth/pkce.d.ts +10 -0
- package/dist/auth/pkce.js +19 -0
- package/dist/auth/session.d.ts +37 -0
- package/dist/auth/session.js +120 -0
- package/dist/auth/token-exchange.d.ts +27 -0
- package/dist/auth/token-exchange.js +26 -0
- package/dist/auth/token-store.d.ts +69 -0
- package/dist/auth/token-store.js +151 -0
- package/dist/config.d.ts +35 -0
- package/dist/config.js +51 -0
- package/dist/main.d.ts +12 -0
- package/dist/main.js +1958 -0
- package/dist/main.js.map +1 -0
- package/dist/resolve.d.ts +18 -0
- package/dist/resolve.js +141 -0
- package/dist/server.d.ts +21 -0
- package/dist/server.js +40 -0
- package/package.json +61 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dan Pillay
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
23
|
+
Portions originally derived from bperkinspdx/yoto-mcp-server, Copyright (c) 2025 bperkinspdx, MIT License.
|
package/README.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# mcp-yoto
|
|
2
|
+
|
|
3
|
+
MCP server for Yoto — connect your Yoto library (cards, tracks, pixel-art icons, family
|
|
4
|
+
players) to Claude, Cursor, VS Code, and other MCP clients that run tools locally.
|
|
5
|
+
|
|
6
|
+
This is the local, command-line connector. It runs on your own machine and signs in
|
|
7
|
+
directly with Yoto — nothing about your account passes through anyone else's server.
|
|
8
|
+
(A remote, paste-one-link connector for claude.ai / ChatGPT is on the way; see the
|
|
9
|
+
[project README](https://github.com/danpillay87/mcp-yoto#readme) for the full story and
|
|
10
|
+
current status.)
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
**Claude Code:**
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
claude mcp add yoto -- npx -y mcp-yoto
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
**Cursor:** click to install — this link is a base64-encoded copy of
|
|
21
|
+
`{"command":"npx","args":["-y","mcp-yoto"]}`:
|
|
22
|
+
|
|
23
|
+
[](cursor://anysphere.cursor-deeplink/mcp/install?name=yoto&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIm1jcC15b3RvIl19)
|
|
24
|
+
|
|
25
|
+
**VS Code:**
|
|
26
|
+
|
|
27
|
+
[](vscode:mcp/install?%7B%22name%22%3A%22yoto%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22mcp-yoto%22%5D%7D)
|
|
28
|
+
|
|
29
|
+
**Any other MCP client:** run `npx -y mcp-yoto` as a stdio server. There's nothing to
|
|
30
|
+
configure beyond that — no API key, no account, no config file to hand-edit.
|
|
31
|
+
|
|
32
|
+
## Sign in
|
|
33
|
+
|
|
34
|
+
After adding the server, sign in once from a terminal:
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
npx mcp-yoto login
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
This opens your browser to **Yoto's own sign-in page**. Approve it there, then close the
|
|
41
|
+
tab — you're done. Your AI client can now list your cards, add tracks, and so on by
|
|
42
|
+
calling the `yoto_*` tools.
|
|
43
|
+
|
|
44
|
+
Your Yoto credential is stored in your OS keychain (Windows Credential Manager, macOS
|
|
45
|
+
Keychain, or the Linux Secret Service) when one is available, or in a plain file
|
|
46
|
+
otherwise. Run `npx mcp-yoto status` any time to see which, and whether you're signed in.
|
|
47
|
+
|
|
48
|
+
To disconnect: `npx mcp-yoto logout`.
|
|
49
|
+
|
|
50
|
+
## Environment variables
|
|
51
|
+
|
|
52
|
+
| Variable | Default | What it does |
|
|
53
|
+
|---|---|---|
|
|
54
|
+
| `YOTO_CLIENT_ID` | (the shared public dev-app id) | Use your own registered Yoto dev app instead. |
|
|
55
|
+
| `YOTO_REDIRECT_PORT` | `8791` | Loopback port used during sign-in. Must match the redirect URI registered at Yoto — only change this if you've registered your own app on a different port. |
|
|
56
|
+
| `YOTO_NO_BROWSER` | unset | Set to `1` to print the sign-in URL instead of opening a browser automatically (useful over SSH). |
|
|
57
|
+
| `LOG_LEVEL` | `info` | `debug` \| `info` \| `warn` \| `error`. Everything goes to stderr — stdout is reserved for the MCP protocol itself. |
|
|
58
|
+
| `MCP_YOTO_ICON_BASE` | GitHub-hosted icon set | Serve tool icons from somewhere else. |
|
|
59
|
+
|
|
60
|
+
## Troubleshooting
|
|
61
|
+
|
|
62
|
+
**"Port 8791 is already in use."** Something else is already listening on the loopback
|
|
63
|
+
port sign-in needs. Close it, or set `YOTO_REDIRECT_PORT` to a free port — but only if
|
|
64
|
+
you've also registered a matching redirect URI on your own Yoto dev app; the shared
|
|
65
|
+
public client is fixed to 8791.
|
|
66
|
+
|
|
67
|
+
**"System keychain unavailable — falling back to a local file."** Your OS has no
|
|
68
|
+
usable keychain right now (common on a headless Linux box with no Secret Service
|
|
69
|
+
running). This isn't fatal: your credential is written to a plain file instead, scoped
|
|
70
|
+
to your user profile. `npx mcp-yoto status` always tells you which store is in use.
|
|
71
|
+
|
|
72
|
+
**"Your Yoto session has expired." / a tool asks you to `yoto_sign_in`.** Your stored
|
|
73
|
+
refresh token has been rejected by Yoto (it may have been revoked from your Yoto account
|
|
74
|
+
settings, or simply expired). Run `npx mcp-yoto login` again.
|
|
75
|
+
|
|
76
|
+
## What this connects to
|
|
77
|
+
|
|
78
|
+
14 tools, all `yoto_*` — cards, tracks, icons, and read-only device status. Never asks
|
|
79
|
+
for `family:devices:control` or `family:devices:manage`, so this stays eligible for
|
|
80
|
+
Yoto's Verified listing. Full tool table, architecture, and privacy details:
|
|
81
|
+
[github.com/danpillay87/mcp-yoto](https://github.com/danpillay87/mcp-yoto#readme).
|
|
82
|
+
|
|
83
|
+
## License
|
|
84
|
+
|
|
85
|
+
MIT — see [LICENSE](LICENSE). Originally derived from
|
|
86
|
+
[bperkinspdx/yoto-mcp-server](https://github.com/bperkinspdx/yoto-mcp-server) (MIT).
|
package/bin/mcp-yoto.mjs
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CLI's `AuthAdapter` (see @mcp-yoto/core's auth.ts): PKCE + loopback +
|
|
3
|
+
* OS keychain, wired together into the shape packages/core's tools expect.
|
|
4
|
+
* apps/worker implements the same interface completely differently (it
|
|
5
|
+
* never signs in locally at all -- see the plan's "remote mode" notes);
|
|
6
|
+
* neither app ever sees the other's implementation.
|
|
7
|
+
*/
|
|
8
|
+
import { type AuthAdapter, type Logger, type SignInResult } from "@mcp-yoto/core";
|
|
9
|
+
import type { CliConfig } from "../config.js";
|
|
10
|
+
import { type TokenStore } from "./token-store.js";
|
|
11
|
+
export interface CreateCliAuthAdapterOptions {
|
|
12
|
+
config: CliConfig;
|
|
13
|
+
logger: Logger;
|
|
14
|
+
/** Injected for tests. */
|
|
15
|
+
fetchImpl?: typeof fetch;
|
|
16
|
+
now?: () => number;
|
|
17
|
+
}
|
|
18
|
+
/** The `AuthAdapter` plus the underlying store, so `main.ts`'s `status`/`logout` subcommands can report its kind directly. */
|
|
19
|
+
export interface CliAuthAdapter extends AuthAdapter {
|
|
20
|
+
readonly tokenStore: TokenStore;
|
|
21
|
+
/**
|
|
22
|
+
* Widens the base `AuthAdapter.signIn` opts with a CLI-only hook: called
|
|
23
|
+
* synchronously with the authorize URL as soon as it's built, before the
|
|
24
|
+
* loopback callback is ever awaited -- so a caller (main.ts's `login`
|
|
25
|
+
* command) can print it immediately instead of waiting for sign-in to
|
|
26
|
+
* finish. Optional and additive; the `yoto_sign_in` tool path never sets it.
|
|
27
|
+
*/
|
|
28
|
+
signIn?(opts?: {
|
|
29
|
+
openBrowser?: boolean;
|
|
30
|
+
onAuthorizeUrl?: (url: string) => void;
|
|
31
|
+
}): Promise<SignInResult>;
|
|
32
|
+
}
|
|
33
|
+
export declare function createCliAuthAdapter(options: CreateCliAuthAdapterOptions): Promise<CliAuthAdapter>;
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CLI's `AuthAdapter` (see @mcp-yoto/core's auth.ts): PKCE + loopback +
|
|
3
|
+
* OS keychain, wired together into the shape packages/core's tools expect.
|
|
4
|
+
* apps/worker implements the same interface completely differently (it
|
|
5
|
+
* never signs in locally at all -- see the plan's "remote mode" notes);
|
|
6
|
+
* neither app ever sees the other's implementation.
|
|
7
|
+
*/
|
|
8
|
+
import { YOTO_SCOPES, YotoError, } from "@mcp-yoto/core";
|
|
9
|
+
import { awaitLoopbackCallback, LoopbackError } from "./loopback.js";
|
|
10
|
+
import { openInBrowser } from "./open-browser.js";
|
|
11
|
+
import { createPkcePair, createState } from "./pkce.js";
|
|
12
|
+
import { decodeJwtExpMs, Session } from "./session.js";
|
|
13
|
+
import { postTokenRequest } from "./token-exchange.js";
|
|
14
|
+
import { createTokenStore } from "./token-store.js";
|
|
15
|
+
function redirectUri(port) {
|
|
16
|
+
return `http://127.0.0.1:${port}/callback`;
|
|
17
|
+
}
|
|
18
|
+
function loopbackErrorToYotoError(error) {
|
|
19
|
+
if (error instanceof LoopbackError) {
|
|
20
|
+
if (error.code === "PORT_IN_USE") {
|
|
21
|
+
return new YotoError(error.message, { code: "PORT_IN_USE", hint: error.hint, cause: error });
|
|
22
|
+
}
|
|
23
|
+
if (error.code === "AUTH_TIMEOUT") {
|
|
24
|
+
return new YotoError(error.message, { code: "VALIDATION", hint: error.hint, cause: error });
|
|
25
|
+
}
|
|
26
|
+
return new YotoError(error.message, { code: "UPSTREAM_ERROR", hint: error.hint, cause: error });
|
|
27
|
+
}
|
|
28
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
29
|
+
return new YotoError(message, { code: "UPSTREAM_ERROR", cause: error });
|
|
30
|
+
}
|
|
31
|
+
export async function createCliAuthAdapter(options) {
|
|
32
|
+
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
33
|
+
const now = options.now ?? (() => Date.now());
|
|
34
|
+
const tokenStore = await createTokenStore({
|
|
35
|
+
account: options.config.clientId,
|
|
36
|
+
logger: options.logger,
|
|
37
|
+
fileOverride: options.config.tokenFileOverride,
|
|
38
|
+
});
|
|
39
|
+
const session = new Session({
|
|
40
|
+
config: { clientId: options.config.clientId, authBase: options.config.authBase },
|
|
41
|
+
tokenStore,
|
|
42
|
+
fetchImpl,
|
|
43
|
+
now,
|
|
44
|
+
});
|
|
45
|
+
async function signIn(opts) {
|
|
46
|
+
const { verifier, challenge } = createPkcePair();
|
|
47
|
+
const state = createState();
|
|
48
|
+
const port = options.config.redirectPort;
|
|
49
|
+
const authorizeUrl = new URL(`${options.config.authBase}/authorize`);
|
|
50
|
+
authorizeUrl.searchParams.set("audience", options.config.audience);
|
|
51
|
+
authorizeUrl.searchParams.set("scope", YOTO_SCOPES.join(" "));
|
|
52
|
+
authorizeUrl.searchParams.set("response_type", "code");
|
|
53
|
+
authorizeUrl.searchParams.set("client_id", options.config.clientId);
|
|
54
|
+
authorizeUrl.searchParams.set("code_challenge", challenge);
|
|
55
|
+
authorizeUrl.searchParams.set("code_challenge_method", "S256");
|
|
56
|
+
authorizeUrl.searchParams.set("redirect_uri", redirectUri(port));
|
|
57
|
+
authorizeUrl.searchParams.set("state", state);
|
|
58
|
+
const url = authorizeUrl.toString();
|
|
59
|
+
// Hand the URL to the caller synchronously, before anything below is
|
|
60
|
+
// awaited -- so a caller who wants to print it (main.ts's `login`
|
|
61
|
+
// command) can do so immediately, rather than waiting for the loopback
|
|
62
|
+
// callback to land. The URL carries only a PKCE challenge and a state
|
|
63
|
+
// nonce, both single-use and non-secret -- safe to print/log.
|
|
64
|
+
opts?.onAuthorizeUrl?.(url);
|
|
65
|
+
// Bind the loopback server BEFORE opening the browser -- a busy port
|
|
66
|
+
// must fail loud, not send the user to a callback URL nothing is
|
|
67
|
+
// listening on.
|
|
68
|
+
const callbackPromise = awaitLoopbackCallback({ port, expectedState: state });
|
|
69
|
+
const shouldOpenBrowser = (opts?.openBrowser ?? true) && !options.config.noBrowser;
|
|
70
|
+
if (shouldOpenBrowser)
|
|
71
|
+
openInBrowser(url);
|
|
72
|
+
let code;
|
|
73
|
+
try {
|
|
74
|
+
({ code } = await callbackPromise);
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
throw loopbackErrorToYotoError(error);
|
|
78
|
+
}
|
|
79
|
+
const result = await postTokenRequest(fetchImpl, `${options.config.authBase}/oauth/token`, {
|
|
80
|
+
grant_type: "authorization_code",
|
|
81
|
+
client_id: options.config.clientId,
|
|
82
|
+
code,
|
|
83
|
+
code_verifier: verifier,
|
|
84
|
+
redirect_uri: redirectUri(port),
|
|
85
|
+
});
|
|
86
|
+
if (!result.ok) {
|
|
87
|
+
throw new YotoError(`Yoto rejected the sign-in code (HTTP ${result.status}${result.error?.error ? `: ${result.error.error}` : ""}).`, { code: "UPSTREAM_ERROR", status: result.status });
|
|
88
|
+
}
|
|
89
|
+
await tokenStore.save({
|
|
90
|
+
accessToken: result.data.access_token,
|
|
91
|
+
refreshToken: result.data.refresh_token,
|
|
92
|
+
expiresAt: result.data.expires_in !== undefined ? now() + result.data.expires_in * 1000 : undefined,
|
|
93
|
+
scope: result.data.scope ?? YOTO_SCOPES.join(" "),
|
|
94
|
+
});
|
|
95
|
+
// ALWAYS return the URL, even after opening the browser automatically --
|
|
96
|
+
// if the browser didn't actually open (headless box, no default handler
|
|
97
|
+
// registered), the caller still has something to paste.
|
|
98
|
+
return { url, message: "Signed in to Yoto." };
|
|
99
|
+
}
|
|
100
|
+
async function signOut() {
|
|
101
|
+
await tokenStore.clear();
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
mode: "cli",
|
|
105
|
+
tokenStore,
|
|
106
|
+
async getAccessToken() {
|
|
107
|
+
return session.getAccessToken();
|
|
108
|
+
},
|
|
109
|
+
async status() {
|
|
110
|
+
const stored = await tokenStore.load();
|
|
111
|
+
if (!stored) {
|
|
112
|
+
return {
|
|
113
|
+
signedIn: false,
|
|
114
|
+
mode: "cli",
|
|
115
|
+
tokenStore: tokenStore.kind,
|
|
116
|
+
hint: "Run yoto_sign_in to connect your Yoto account.",
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
const expiresAt = decodeJwtExpMs(stored.accessToken) ?? stored.expiresAt;
|
|
120
|
+
return {
|
|
121
|
+
signedIn: true,
|
|
122
|
+
mode: "cli",
|
|
123
|
+
tokenStore: tokenStore.kind,
|
|
124
|
+
expiresAt,
|
|
125
|
+
scopes: stored.scope ? stored.scope.split(" ") : undefined,
|
|
126
|
+
};
|
|
127
|
+
},
|
|
128
|
+
signIn,
|
|
129
|
+
signOut,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Distinguishes the two CLI-only failure shapes from the shared `YotoErrorCode` union in packages/core. */
|
|
2
|
+
export type LoopbackErrorCode = "PORT_IN_USE" | "AUTH_TIMEOUT" | "CALLBACK_ERROR";
|
|
3
|
+
export declare class LoopbackError extends Error {
|
|
4
|
+
readonly code: LoopbackErrorCode;
|
|
5
|
+
readonly hint: string | undefined;
|
|
6
|
+
constructor(message: string, code: LoopbackErrorCode, hint?: string);
|
|
7
|
+
}
|
|
8
|
+
export interface LoopbackOptions {
|
|
9
|
+
port: number;
|
|
10
|
+
expectedState: string;
|
|
11
|
+
/** How long to wait for the callback before giving up. Default 300_000ms (300s). */
|
|
12
|
+
timeoutMs?: number;
|
|
13
|
+
/** Injected timer functions, for deterministic tests with fake timers. */
|
|
14
|
+
setTimeoutImpl?: typeof setTimeout;
|
|
15
|
+
clearTimeoutImpl?: typeof clearTimeout;
|
|
16
|
+
}
|
|
17
|
+
export interface LoopbackResult {
|
|
18
|
+
code: string;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Waits for the single OAuth callback this sign-in attempt expects.
|
|
22
|
+
*
|
|
23
|
+
* Binds the port before returning, so a caller can `await` this and only
|
|
24
|
+
* then open the browser / print the sign-in URL, confident the redirect has
|
|
25
|
+
* somewhere to land. Every exit path (success, state mismatch, upstream
|
|
26
|
+
* `error=`, bind failure, timeout) closes the server exactly once.
|
|
27
|
+
*/
|
|
28
|
+
export declare function awaitLoopbackCallback(options: LoopbackOptions): Promise<LoopbackResult>;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CLI's one-shot loopback HTTP server: binds `127.0.0.1:<port>` FIRST
|
|
3
|
+
* (so a busy port fails loud and fast, before the browser even opens),
|
|
4
|
+
* waits for exactly one `/callback` request carrying `state` + `code`, and
|
|
5
|
+
* always closes the server afterwards -- on success, on a state mismatch,
|
|
6
|
+
* on an upstream error, or on timeout.
|
|
7
|
+
*/
|
|
8
|
+
import { createServer } from "node:http";
|
|
9
|
+
export class LoopbackError extends Error {
|
|
10
|
+
code;
|
|
11
|
+
hint;
|
|
12
|
+
constructor(message, code, hint) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = "LoopbackError";
|
|
15
|
+
this.code = code;
|
|
16
|
+
this.hint = hint;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const SUCCESS_HTML = '<!doctype html><html><head><meta charset="utf-8"><title>Signed in to Yoto</title></head>' +
|
|
20
|
+
'<body style="font-family:system-ui,sans-serif;padding:3rem;max-width:32rem;margin:0 auto">' +
|
|
21
|
+
"<h1>You're signed in — you can close this tab.</h1>" +
|
|
22
|
+
"<p>Head back to your AI client -- mcp-yoto is ready.</p></body></html>";
|
|
23
|
+
function failureHtml(message) {
|
|
24
|
+
return ('<!doctype html><html><head><meta charset="utf-8"><title>Sign-in failed</title></head>' +
|
|
25
|
+
'<body style="font-family:system-ui,sans-serif;padding:3rem;max-width:32rem;margin:0 auto">' +
|
|
26
|
+
"<h1>Sign-in failed</h1>" +
|
|
27
|
+
`<p>${escapeHtml(message)}</p></body></html>`);
|
|
28
|
+
}
|
|
29
|
+
const HTML_ESCAPES = {
|
|
30
|
+
"&": "&",
|
|
31
|
+
"<": "<",
|
|
32
|
+
">": ">",
|
|
33
|
+
'"': """,
|
|
34
|
+
"'": "'",
|
|
35
|
+
};
|
|
36
|
+
function escapeHtml(value) {
|
|
37
|
+
return value.replace(/[&<>"']/g, (ch) => HTML_ESCAPES[ch] ?? ch);
|
|
38
|
+
}
|
|
39
|
+
function sendHtml(res, status, body) {
|
|
40
|
+
res.writeHead(status, { "Content-Type": "text/html; charset=utf-8" });
|
|
41
|
+
res.end(body);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Waits for the single OAuth callback this sign-in attempt expects.
|
|
45
|
+
*
|
|
46
|
+
* Binds the port before returning, so a caller can `await` this and only
|
|
47
|
+
* then open the browser / print the sign-in URL, confident the redirect has
|
|
48
|
+
* somewhere to land. Every exit path (success, state mismatch, upstream
|
|
49
|
+
* `error=`, bind failure, timeout) closes the server exactly once.
|
|
50
|
+
*/
|
|
51
|
+
export function awaitLoopbackCallback(options) {
|
|
52
|
+
const timeoutMs = options.timeoutMs ?? 300_000;
|
|
53
|
+
const setTimeoutImpl = options.setTimeoutImpl ?? setTimeout;
|
|
54
|
+
const clearTimeoutImpl = options.clearTimeoutImpl ?? clearTimeout;
|
|
55
|
+
return new Promise((resolve, reject) => {
|
|
56
|
+
let settled = false;
|
|
57
|
+
let timer;
|
|
58
|
+
const server = createServer(handleRequest);
|
|
59
|
+
function finish(action) {
|
|
60
|
+
if (settled)
|
|
61
|
+
return;
|
|
62
|
+
settled = true;
|
|
63
|
+
if (timer !== undefined)
|
|
64
|
+
clearTimeoutImpl(timer);
|
|
65
|
+
server.close();
|
|
66
|
+
action();
|
|
67
|
+
}
|
|
68
|
+
function handleRequest(req, res) {
|
|
69
|
+
const url = new URL(req.url ?? "/", `http://127.0.0.1:${options.port}`);
|
|
70
|
+
if (url.pathname !== "/callback") {
|
|
71
|
+
res.writeHead(404).end();
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const upstreamError = url.searchParams.get("error");
|
|
75
|
+
if (upstreamError) {
|
|
76
|
+
sendHtml(res, 200, failureHtml(`Yoto returned an error: ${upstreamError}`));
|
|
77
|
+
finish(() => reject(new LoopbackError(`Yoto returned an error during sign-in: ${upstreamError}`, "CALLBACK_ERROR")));
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const code = url.searchParams.get("code");
|
|
81
|
+
const state = url.searchParams.get("state");
|
|
82
|
+
if (!code || state !== options.expectedState) {
|
|
83
|
+
sendHtml(res, 400, failureHtml("This sign-in link is stale or was tampered with."));
|
|
84
|
+
finish(() => reject(new LoopbackError("Sign-in callback state did not match -- stale or invalid attempt.", "CALLBACK_ERROR", "Run yoto_sign_in again.")));
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
sendHtml(res, 200, SUCCESS_HTML);
|
|
88
|
+
finish(() => resolve({ code }));
|
|
89
|
+
}
|
|
90
|
+
server.once("error", (err) => {
|
|
91
|
+
if (err.code === "EADDRINUSE") {
|
|
92
|
+
finish(() => reject(new LoopbackError(`Port ${options.port} is already in use.`, "PORT_IN_USE", "Set YOTO_REDIRECT_PORT to a free port that's also registered at Yoto, or close whatever's using it.")));
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
finish(() => reject(new LoopbackError(err.message, "CALLBACK_ERROR")));
|
|
96
|
+
});
|
|
97
|
+
server.listen(options.port, "127.0.0.1", () => {
|
|
98
|
+
timer = setTimeoutImpl(() => {
|
|
99
|
+
finish(() => reject(new LoopbackError("Timed out waiting for the Yoto sign-in callback.", "AUTH_TIMEOUT", "Run yoto_sign_in again.")));
|
|
100
|
+
}, timeoutMs);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function openInBrowser(url: string): void;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Best-effort cross-platform "open this URL in the default browser". Never
|
|
3
|
+
* throws: the sign-in URL is always returned/printed alongside this call
|
|
4
|
+
* too (see adapter.ts), so a headless box or a missing `xdg-open` degrades
|
|
5
|
+
* to "copy this link" rather than a hard failure.
|
|
6
|
+
*/
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
export function openInBrowser(url) {
|
|
9
|
+
try {
|
|
10
|
+
let child;
|
|
11
|
+
if (process.platform === "win32") {
|
|
12
|
+
// NOT `cmd /c start`: cmd.exe parses the command line itself, and it
|
|
13
|
+
// treats every unquoted `&` as a command separator -- an OAuth
|
|
14
|
+
// authorize URL is full of `&`-joined query params, so `cmd start`
|
|
15
|
+
// silently truncated the URL at the first `&` (client_id,
|
|
16
|
+
// redirect_uri, code_challenge, state all dropped) and Yoto's sign-in
|
|
17
|
+
// page rendered with no way to authenticate. `rundll32.exe
|
|
18
|
+
// url.dll,FileProtocolHandler <url>` hands the URL to the shell's own
|
|
19
|
+
// protocol handler with no cmd/shell parsing involved, so the URL
|
|
20
|
+
// reaches Edge byte-for-byte.
|
|
21
|
+
child = spawn("rundll32.exe", ["url.dll,FileProtocolHandler", url], {
|
|
22
|
+
shell: false,
|
|
23
|
+
detached: true,
|
|
24
|
+
stdio: "ignore",
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
else if (process.platform === "darwin") {
|
|
28
|
+
child = spawn("open", [url], { shell: false, detached: true, stdio: "ignore" });
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
child = spawn("xdg-open", [url], { shell: false, detached: true, stdio: "ignore" });
|
|
32
|
+
}
|
|
33
|
+
// A missing binary (e.g. no xdg-open on a headless box) emits an async
|
|
34
|
+
// `error` event rather than throwing -- swallow it the same way as the
|
|
35
|
+
// synchronous catch below, since the URL is already on stderr for the
|
|
36
|
+
// user to click (see login's stderr contract in adapter.ts).
|
|
37
|
+
child.on("error", () => { });
|
|
38
|
+
child.unref();
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// Best-effort only.
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** RFC 7636 S256 `code_challenge` for a given `code_verifier`. Exported standalone for tests. */
|
|
2
|
+
export declare function codeChallengeFromVerifier(verifier: string): string;
|
|
3
|
+
export interface PkcePair {
|
|
4
|
+
verifier: string;
|
|
5
|
+
challenge: string;
|
|
6
|
+
}
|
|
7
|
+
/** A fresh PKCE pair: a 32-byte random verifier (well within RFC 7636's 43-128 char range), S256 challenge. */
|
|
8
|
+
export declare function createPkcePair(): PkcePair;
|
|
9
|
+
/** A single-use value correlating the loopback callback with the request that started it. */
|
|
10
|
+
export declare function createState(): string;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PKCE (RFC 7636) helpers for the CLI's loopback sign-in flow. Node-only
|
|
3
|
+
* (`node:crypto`) -- apps/cli is allowed node: imports, unlike
|
|
4
|
+
* packages/core, which stays fetch + WebCrypto only.
|
|
5
|
+
*/
|
|
6
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
7
|
+
/** RFC 7636 S256 `code_challenge` for a given `code_verifier`. Exported standalone for tests. */
|
|
8
|
+
export function codeChallengeFromVerifier(verifier) {
|
|
9
|
+
return createHash("sha256").update(verifier).digest("base64url");
|
|
10
|
+
}
|
|
11
|
+
/** A fresh PKCE pair: a 32-byte random verifier (well within RFC 7636's 43-128 char range), S256 challenge. */
|
|
12
|
+
export function createPkcePair() {
|
|
13
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
14
|
+
return { verifier, challenge: codeChallengeFromVerifier(verifier) };
|
|
15
|
+
}
|
|
16
|
+
/** A single-use value correlating the loopback callback with the request that started it. */
|
|
17
|
+
export function createState() {
|
|
18
|
+
return randomBytes(16).toString("base64url");
|
|
19
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { TokenStore } from "./token-store.js";
|
|
2
|
+
export interface SessionConfig {
|
|
3
|
+
clientId: string;
|
|
4
|
+
authBase: string;
|
|
5
|
+
}
|
|
6
|
+
export interface SessionOptions {
|
|
7
|
+
config: SessionConfig;
|
|
8
|
+
tokenStore: TokenStore;
|
|
9
|
+
/** Injected for tests; defaults to `globalThis.fetch`. */
|
|
10
|
+
fetchImpl?: typeof fetch;
|
|
11
|
+
/** Injected clock, for deterministic early-refresh tests. Defaults to `Date.now`. */
|
|
12
|
+
now?: () => number;
|
|
13
|
+
}
|
|
14
|
+
/** Reads the `exp` claim (ms since epoch) out of a JWT's payload, unverified -- we trust it because Yoto issued it to us. */
|
|
15
|
+
export declare function decodeJwtExpMs(token: string): number | undefined;
|
|
16
|
+
/**
|
|
17
|
+
* Manages the CLI's single Yoto session (there is only ever one -- no
|
|
18
|
+
* multi-account support in this pass). One `Session` instance is created
|
|
19
|
+
* per process and shared by every tool call via the `AuthAdapter`.
|
|
20
|
+
*/
|
|
21
|
+
export declare class Session {
|
|
22
|
+
private readonly config;
|
|
23
|
+
private readonly tokenStore;
|
|
24
|
+
private readonly fetchImpl;
|
|
25
|
+
private readonly now;
|
|
26
|
+
private inFlightRefresh;
|
|
27
|
+
constructor(options: SessionOptions);
|
|
28
|
+
/**
|
|
29
|
+
* Returns a live access token, refreshing first if the current one is
|
|
30
|
+
* within 10 minutes of expiry (or the store carries no readable expiry
|
|
31
|
+
* at all, which fails safe by treating it as due for refresh).
|
|
32
|
+
*/
|
|
33
|
+
getAccessToken(): Promise<string>;
|
|
34
|
+
/** Single-flight: concurrent callers share the one in-flight refresh instead of each starting their own. */
|
|
35
|
+
private refresh;
|
|
36
|
+
private doRefresh;
|
|
37
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turns a `TokenStore` into a live access token: reads the JWT's own `exp`
|
|
3
|
+
* claim and trusts it over whatever `expiresAt` was persisted (ported from
|
|
4
|
+
* the old yoto-mcp-server, which found a stored placeholder `expiresAt`
|
|
5
|
+
* making a perfectly live token look dead), refreshes 10 minutes early so a
|
|
6
|
+
* rejected refresh never leaves the caller with zero working credential,
|
|
7
|
+
* and collapses concurrent callers onto one in-flight refresh so three
|
|
8
|
+
* simultaneous tool calls never race Yoto's rotating refresh token against
|
|
9
|
+
* itself.
|
|
10
|
+
*/
|
|
11
|
+
import { YotoError } from "@mcp-yoto/core";
|
|
12
|
+
import { postTokenRequest } from "./token-exchange.js";
|
|
13
|
+
const REFRESH_SKEW_MS = 10 * 60 * 1000;
|
|
14
|
+
/** Reads the `exp` claim (ms since epoch) out of a JWT's payload, unverified -- we trust it because Yoto issued it to us. */
|
|
15
|
+
export function decodeJwtExpMs(token) {
|
|
16
|
+
const parts = token.split(".");
|
|
17
|
+
if (parts.length !== 3)
|
|
18
|
+
return undefined;
|
|
19
|
+
try {
|
|
20
|
+
const payloadJson = Buffer.from(parts[1] ?? "", "base64url").toString("utf-8");
|
|
21
|
+
const payload = JSON.parse(payloadJson);
|
|
22
|
+
return typeof payload.exp === "number" ? payload.exp * 1000 : undefined;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Manages the CLI's single Yoto session (there is only ever one -- no
|
|
30
|
+
* multi-account support in this pass). One `Session` instance is created
|
|
31
|
+
* per process and shared by every tool call via the `AuthAdapter`.
|
|
32
|
+
*/
|
|
33
|
+
export class Session {
|
|
34
|
+
config;
|
|
35
|
+
tokenStore;
|
|
36
|
+
fetchImpl;
|
|
37
|
+
now;
|
|
38
|
+
inFlightRefresh;
|
|
39
|
+
constructor(options) {
|
|
40
|
+
this.config = options.config;
|
|
41
|
+
this.tokenStore = options.tokenStore;
|
|
42
|
+
this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
43
|
+
this.now = options.now ?? (() => Date.now());
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Returns a live access token, refreshing first if the current one is
|
|
47
|
+
* within 10 minutes of expiry (or the store carries no readable expiry
|
|
48
|
+
* at all, which fails safe by treating it as due for refresh).
|
|
49
|
+
*/
|
|
50
|
+
async getAccessToken() {
|
|
51
|
+
const stored = await this.tokenStore.load();
|
|
52
|
+
if (!stored) {
|
|
53
|
+
throw new YotoError("Not signed in to Yoto.", {
|
|
54
|
+
code: "NOT_AUTHENTICATED",
|
|
55
|
+
hint: "Run yoto_sign_in.",
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
const expiresAt = decodeJwtExpMs(stored.accessToken) ?? stored.expiresAt;
|
|
59
|
+
const now = this.now();
|
|
60
|
+
if (expiresAt !== undefined && expiresAt - REFRESH_SKEW_MS > now) {
|
|
61
|
+
return stored.accessToken;
|
|
62
|
+
}
|
|
63
|
+
if (!stored.refreshToken) {
|
|
64
|
+
// No refresh token on file: a live-but-soon-expiring token is still
|
|
65
|
+
// usable, but once it's actually dead there is nothing left to try.
|
|
66
|
+
if (expiresAt !== undefined && expiresAt > now)
|
|
67
|
+
return stored.accessToken;
|
|
68
|
+
throw new YotoError("Your Yoto session has expired.", {
|
|
69
|
+
code: "AUTH_EXPIRED",
|
|
70
|
+
hint: "Run yoto_sign_in.",
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
return this.refresh(stored.refreshToken);
|
|
74
|
+
}
|
|
75
|
+
/** Single-flight: concurrent callers share the one in-flight refresh instead of each starting their own. */
|
|
76
|
+
refresh(refreshToken) {
|
|
77
|
+
if (!this.inFlightRefresh) {
|
|
78
|
+
this.inFlightRefresh = this.doRefresh(refreshToken).finally(() => {
|
|
79
|
+
this.inFlightRefresh = undefined;
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
return this.inFlightRefresh;
|
|
83
|
+
}
|
|
84
|
+
async doRefresh(refreshToken) {
|
|
85
|
+
const result = await postTokenRequest(this.fetchImpl, `${this.config.authBase}/oauth/token`, {
|
|
86
|
+
grant_type: "refresh_token",
|
|
87
|
+
refresh_token: refreshToken,
|
|
88
|
+
client_id: this.config.clientId,
|
|
89
|
+
});
|
|
90
|
+
if (!result.ok) {
|
|
91
|
+
if (result.error?.error === "invalid_grant") {
|
|
92
|
+
// Yoto has already invalidated this refresh token server-side (it's
|
|
93
|
+
// rotating) -- there is nothing to retry. Clear the local store so
|
|
94
|
+
// yoto_status reports "not signed in" rather than a token we know is dead.
|
|
95
|
+
await this.tokenStore.clear();
|
|
96
|
+
throw new YotoError("Yoto rejected the stored refresh token.", {
|
|
97
|
+
code: "AUTH_EXPIRED",
|
|
98
|
+
hint: "Run yoto_sign_in.",
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
throw new YotoError(`Yoto token refresh failed (HTTP ${result.status}).`, {
|
|
102
|
+
code: "UPSTREAM_ERROR",
|
|
103
|
+
retryable: true,
|
|
104
|
+
status: result.status,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
const data = result.data;
|
|
108
|
+
const newTokens = {
|
|
109
|
+
accessToken: data.access_token,
|
|
110
|
+
refreshToken: data.refresh_token ?? refreshToken,
|
|
111
|
+
expiresAt: data.expires_in !== undefined ? this.now() + data.expires_in * 1000 : undefined,
|
|
112
|
+
scope: data.scope,
|
|
113
|
+
};
|
|
114
|
+
// Persist BEFORE returning: if the process dies right after this call,
|
|
115
|
+
// the rotating refresh token Yoto just invalidated server-side must not
|
|
116
|
+
// be lost along with it -- the new one has to be on disk first.
|
|
117
|
+
await this.tokenStore.save(newTokens);
|
|
118
|
+
return newTokens.accessToken;
|
|
119
|
+
}
|
|
120
|
+
}
|