@autolane/courier-mcp 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +64 -2
- package/dist/bin.js +10 -0
- package/dist/cli.js +88 -0
- package/dist/config.js +34 -0
- package/dist/errors.js +16 -0
- package/dist/login.js +49 -0
- package/dist/oauth.js +126 -0
- package/dist/proxy.js +99 -0
- package/dist/session.js +49 -0
- package/dist/store.js +101 -0
- package/package.json +40 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Autolane
|
|
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.
|
package/README.md
CHANGED
|
@@ -1,3 +1,65 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @autolane/courier-mcp
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Use [Courier by Autolane](https://docs.goautolane.com/courier/get-started) from an AI agent on any machine, including one with no browser: a server, a container, or a computer you reach over SSH.
|
|
4
|
+
|
|
5
|
+
This package runs a small MCP server on your machine. It links to your phone once, keeps its own login, and passes your agent's requests to Autolane at `https://mcp.goautolane.com/courier-mcp`. Any agent that can run a local (stdio) MCP server can use it, even one without OAuth.
|
|
6
|
+
|
|
7
|
+
## You probably don't need this package
|
|
8
|
+
|
|
9
|
+
Most AI assistants connect to Courier by Autolane directly: add `https://mcp.goautolane.com/courier-mcp` as a custom connector or MCP server, link your phone, and you're done. Nothing to install.
|
|
10
|
+
|
|
11
|
+
- **Claude** (claude.ai, Claude Desktop, phone apps): Customize, Connectors, + Add, Add custom connector. Name `Courier by Autolane`, URL `https://mcp.goautolane.com/courier-mcp`. Select Connect, then enter your mobile number and the 6-digit code Autolane texts you.
|
|
12
|
+
- **ChatGPT** (web, and your dots): chatgpt.com/plugins, +, Add custom MCP server. Name `Courier by Autolane`, Server URL `https://mcp.goautolane.com/courier-mcp`, Authentication OAuth (leave client ID and secret empty). Select Connect and link your phone.
|
|
13
|
+
- **Claude Code**: `claude mcp add --transport http --scope user courier-by-autolane https://mcp.goautolane.com/courier-mcp`, then `claude mcp login courier-by-autolane`.
|
|
14
|
+
- **Codex** (CLI, IDE extension, ChatGPT desktop app): `codex mcp add courier-by-autolane --url https://mcp.goautolane.com/courier-mcp`. Codex opens your browser; if not, run `codex mcp login courier-by-autolane`.
|
|
15
|
+
- **OpenClaw**: `openclaw mcp add courier-by-autolane --url https://mcp.goautolane.com/courier-mcp --transport streamable-http --auth oauth`, then `openclaw mcp login courier-by-autolane`.
|
|
16
|
+
- **Hermes**: add this custom MCP entry, then open its config and select Retry, then Authorize:
|
|
17
|
+
|
|
18
|
+
```json
|
|
19
|
+
{ "mcpServers": { "Courier by Autolane": { "url": "https://mcp.goautolane.com/courier-mcp", "auth": "oauth" } } }
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
- **Muse** (Meta AI): ask "Connect the Courier by Autolane MCP at https://mcp.goautolane.com/courier-mcp", open the link it shows, and check the code matches.
|
|
23
|
+
|
|
24
|
+
Full guides: [docs.goautolane.com/courier/get-started](https://docs.goautolane.com/courier/get-started).
|
|
25
|
+
|
|
26
|
+
**Terminal agent on another machine** (over SSH or on a server)? After you link your phone, the Autolane page asks where the agent is running. Pick **On another computer** and paste the link it shows where the agent asks for one. If the agent doesn't ask, open "isn't asking for a link?" and run the `curl` command it shows on the agent's machine.
|
|
27
|
+
|
|
28
|
+
## When to use this package
|
|
29
|
+
|
|
30
|
+
Use it when your agent runs on a machine with no browser and you'd rather link with a code from your phone, or when your agent can run a local (stdio) MCP server but can't sign in to one with OAuth.
|
|
31
|
+
|
|
32
|
+
### Link your phone
|
|
33
|
+
|
|
34
|
+
On the machine where your agent runs:
|
|
35
|
+
|
|
36
|
+
npx -y @autolane/courier-mcp login --no-browser
|
|
37
|
+
|
|
38
|
+
Open the link it prints on any device (your phone works), check the page shows the same code, and enter your mobile number and the 6-digit code Autolane texts you. The command finishes by itself. Leave out `--no-browser` to have it open the link in a browser on this machine.
|
|
39
|
+
|
|
40
|
+
Courier by Autolane is invite-only for now. If the page says the service isn't available for your number, [join the waitlist](https://goautolane.com/autolane-courier).
|
|
41
|
+
|
|
42
|
+
### Add it to your agent
|
|
43
|
+
|
|
44
|
+
Claude Code:
|
|
45
|
+
|
|
46
|
+
claude mcp add --scope user courier-by-autolane -- npx -y @autolane/courier-mcp
|
|
47
|
+
|
|
48
|
+
Any other agent: add a local (stdio) MCP server with the command `npx` and the arguments `-y @autolane/courier-mcp`.
|
|
49
|
+
|
|
50
|
+
## Commands
|
|
51
|
+
|
|
52
|
+
| Command | Does |
|
|
53
|
+
| --- | --- |
|
|
54
|
+
| `npx -y @autolane/courier-mcp` | Runs the MCP server on stdio. Your agent's config runs this. |
|
|
55
|
+
| `login [--no-browser]` | Links this machine to your phone. |
|
|
56
|
+
| `logout` | Revokes this machine's link and removes it. |
|
|
57
|
+
| `status` | Shows whether this machine is linked. |
|
|
58
|
+
|
|
59
|
+
## Where the login lives
|
|
60
|
+
|
|
61
|
+
`~/.config/autolane/courier-mcp.json` (or `$XDG_CONFIG_HOME/autolane/` when set; `%APPDATA%\autolane\` on Windows), readable only by you. It holds a short-lived access token and a refresh token, never your phone number. Several agents on one machine can share it. Run `logout` to remove it.
|
|
62
|
+
|
|
63
|
+
## Help
|
|
64
|
+
|
|
65
|
+
support@goautolane.com
|
package/dist/bin.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { main } from './cli.js';
|
|
3
|
+
import { describeError } from './errors.js';
|
|
4
|
+
main(process.argv.slice(2), { env: process.env, out: (line) => console.log(line), err: (line) => console.error(line) }).then((code) => {
|
|
5
|
+
if (code !== null)
|
|
6
|
+
process.exitCode = code;
|
|
7
|
+
}, (error) => {
|
|
8
|
+
console.error(describeError(error));
|
|
9
|
+
process.exitCode = 1;
|
|
10
|
+
});
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// The commands (spec section 4.1). Everything a command prints goes through `out`/`err`; serve prints nothing to
|
|
2
|
+
// stdout, which carries MCP messages.
|
|
3
|
+
import { parseArgs } from 'node:util';
|
|
4
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
5
|
+
import { RELINK, VERSION, configDir, serverUrl } from './config.js';
|
|
6
|
+
import { describeError } from './errors.js';
|
|
7
|
+
import { login } from './login.js';
|
|
8
|
+
import { discover, revoke } from './oauth.js';
|
|
9
|
+
import { createProxy } from './proxy.js';
|
|
10
|
+
import { createSession } from './session.js';
|
|
11
|
+
import { fileStore } from './store.js';
|
|
12
|
+
export const USAGE = `Usage: npx -y @autolane/courier-mcp [command]
|
|
13
|
+
|
|
14
|
+
(no command) Run the MCP server on stdio; this is what your agent's config runs
|
|
15
|
+
login [--no-browser] Link this machine to your phone
|
|
16
|
+
logout Remove this machine's link
|
|
17
|
+
status Show whether this machine is linked
|
|
18
|
+
|
|
19
|
+
AUTOLANE_COURIER_URL changes the server (default https://mcp.goautolane.com/courier-mcp).`;
|
|
20
|
+
/** Runs one command and resolves to its exit code, or to null for serve, which keeps running. */
|
|
21
|
+
export async function main(argv, deps) {
|
|
22
|
+
const { values, positionals } = parseArgs({
|
|
23
|
+
args: argv,
|
|
24
|
+
allowPositionals: true,
|
|
25
|
+
options: { 'no-browser': { type: 'boolean', default: false }, help: { type: 'boolean', short: 'h', default: false }, version: { type: 'boolean', default: false } },
|
|
26
|
+
});
|
|
27
|
+
if (values.help) {
|
|
28
|
+
deps.out(USAGE);
|
|
29
|
+
return 0;
|
|
30
|
+
}
|
|
31
|
+
if (values.version) {
|
|
32
|
+
deps.out(VERSION);
|
|
33
|
+
return 0;
|
|
34
|
+
}
|
|
35
|
+
const server = serverUrl(deps.env);
|
|
36
|
+
const store = fileStore(configDir(deps.env), server);
|
|
37
|
+
const now = deps.now ?? Date.now;
|
|
38
|
+
const [command = 'serve', ...rest] = positionals;
|
|
39
|
+
if (rest.length > 0) {
|
|
40
|
+
deps.err(USAGE);
|
|
41
|
+
return 2;
|
|
42
|
+
}
|
|
43
|
+
switch (command) {
|
|
44
|
+
case 'login':
|
|
45
|
+
await login({ server, store, noBrowser: values['no-browser'], print: deps.out, openUrl: deps.openUrl, fetch: deps.fetch, sleep: deps.sleep, now });
|
|
46
|
+
return 0;
|
|
47
|
+
case 'logout':
|
|
48
|
+
// Under the lock: a refresh in an agent must not rotate the token between our read and our revoke.
|
|
49
|
+
return store.withLock(async () => {
|
|
50
|
+
const saved = await store.read();
|
|
51
|
+
if (!saved) {
|
|
52
|
+
deps.out('This machine is not linked.');
|
|
53
|
+
return 0;
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
await revoke(await discover(server, deps.fetch), saved.client_id, saved.refresh_token, deps.fetch);
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
deps.err(`Could not reach Autolane to revoke the link (${describeError(error)}); removing it from this machine anyway.`);
|
|
60
|
+
}
|
|
61
|
+
await store.remove();
|
|
62
|
+
deps.out('Logged out. This machine is no longer linked.');
|
|
63
|
+
return 0;
|
|
64
|
+
});
|
|
65
|
+
case 'status': {
|
|
66
|
+
const saved = await store.read();
|
|
67
|
+
if (!saved) {
|
|
68
|
+
deps.out(`Not linked to ${server}.`);
|
|
69
|
+
return 1;
|
|
70
|
+
}
|
|
71
|
+
const access = saved.access_expires_at > now() ? `valid until ${new Date(saved.access_expires_at).toISOString()}` : 'expired; it refreshes on the next call';
|
|
72
|
+
deps.out(`Linked to ${server}.\nAccess token ${access}.\nToken file: ${store.path}`);
|
|
73
|
+
return 0;
|
|
74
|
+
}
|
|
75
|
+
case 'serve': {
|
|
76
|
+
if (!(await store.read())) {
|
|
77
|
+
deps.err(RELINK);
|
|
78
|
+
return 1;
|
|
79
|
+
}
|
|
80
|
+
const session = createSession({ store, endpoints: () => discover(server, deps.fetch), fetch: deps.fetch, now });
|
|
81
|
+
await createProxy({ server, session, fetch: deps.fetch }).connect(deps.transport?.() ?? new StdioServerTransport());
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
default:
|
|
85
|
+
deps.err(USAGE);
|
|
86
|
+
return 2;
|
|
87
|
+
}
|
|
88
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Where the package talks to, and where it keeps its login (spec section 4.4).
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { posix, win32, join } from 'node:path';
|
|
5
|
+
export const DEFAULT_SERVER = 'https://mcp.goautolane.com/courier-mcp';
|
|
6
|
+
export const RELINK = 'Not linked. Run: npx -y @autolane/courier-mcp login --no-browser';
|
|
7
|
+
/** The client_name the phone page shows: "Link your phone to Courier by Autolane MCP". */
|
|
8
|
+
export const CLIENT_NAME = 'Courier by Autolane MCP';
|
|
9
|
+
export const VERSION = createRequire(import.meta.url)('../package.json').version;
|
|
10
|
+
const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1']);
|
|
11
|
+
/** The courier MCP endpoint: AUTOLANE_COURIER_URL (sandbox, or a local dev server over http), else production. */
|
|
12
|
+
export function serverUrl(env = process.env) {
|
|
13
|
+
const raw = env.AUTOLANE_COURIER_URL?.trim() || DEFAULT_SERVER;
|
|
14
|
+
let url;
|
|
15
|
+
try {
|
|
16
|
+
url = new URL(raw);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
throw new Error(`AUTOLANE_COURIER_URL is not a URL: ${raw}`);
|
|
20
|
+
}
|
|
21
|
+
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && LOCAL_HOSTS.has(url.hostname))) {
|
|
22
|
+
throw new Error(`AUTOLANE_COURIER_URL must be an https URL (or http on localhost): ${raw}`);
|
|
23
|
+
}
|
|
24
|
+
return url.toString().replace(/\/$/, '');
|
|
25
|
+
}
|
|
26
|
+
/** The token file's directory: %APPDATA%\autolane on Windows, else $XDG_CONFIG_HOME/autolane or ~/.config/autolane. */
|
|
27
|
+
export function configDir(env = process.env, platform = process.platform) {
|
|
28
|
+
// The XDG spec says to ignore a relative path, and a relative APPDATA would put the token file in the working directory.
|
|
29
|
+
const absolute = (path) => (path && (platform === 'win32' ? win32 : posix).isAbsolute(path) ? path : undefined);
|
|
30
|
+
const appData = platform === 'win32' ? absolute(env.APPDATA) : undefined;
|
|
31
|
+
if (appData)
|
|
32
|
+
return join(appData, 'autolane');
|
|
33
|
+
return join(absolute(env.XDG_CONFIG_HOME) ?? join(homedir(), '.config'), 'autolane');
|
|
34
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// One way to say why something failed, for the CLI and the proxy alike.
|
|
2
|
+
/** An error's message, with its cause's in parentheses: fetch says only "fetch failed" and keeps the reason in `cause`. */
|
|
3
|
+
export function describeError(error) {
|
|
4
|
+
if (!(error instanceof Error))
|
|
5
|
+
return String(error);
|
|
6
|
+
const cause = error.cause;
|
|
7
|
+
if (!(cause instanceof Error))
|
|
8
|
+
return error.message;
|
|
9
|
+
// A dual-stack connect failure is an AggregateError with no message; the reason is in its first inner error.
|
|
10
|
+
const inner = cause instanceof AggregateError ? cause.errors[0] : undefined;
|
|
11
|
+
const reason = cause.message || inner?.message || inner?.code || cause.code || cause.name;
|
|
12
|
+
return `${error.message} (${reason})`;
|
|
13
|
+
}
|
|
14
|
+
/** Text from a server, safe to print: control characters (an escape sequence, a newline) are dropped. */
|
|
15
|
+
// eslint-disable-next-line no-control-regex
|
|
16
|
+
export const plain = (text) => text.replace(/[\x00-\x1f\x7f]/g, '');
|
package/dist/login.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// `login`: RFC 8628 from a terminal (spec section 4.2). Print the link and code, open the link unless
|
|
2
|
+
// --no-browser, wait while the user links their phone on any device, then save the tokens.
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { CLIENT_NAME } from './config.js';
|
|
5
|
+
import { describeError, plain } from './errors.js';
|
|
6
|
+
import { OAuthError, discover, pollDevice, register, revoke, startDevice } from './oauth.js';
|
|
7
|
+
import { savedFrom } from './session.js';
|
|
8
|
+
export async function login(opts) {
|
|
9
|
+
const endpoints = await discover(opts.server, opts.fetch);
|
|
10
|
+
const savedClient = (await opts.store.read())?.client_id;
|
|
11
|
+
let clientId = savedClient ?? (await register(endpoints, CLIENT_NAME, opts.fetch));
|
|
12
|
+
let start;
|
|
13
|
+
try {
|
|
14
|
+
start = await startDevice(endpoints, clientId, opts.fetch);
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
// The server forgot the saved client: register a new one, once.
|
|
18
|
+
if (!savedClient || !(error instanceof OAuthError) || error.code !== 'invalid_client')
|
|
19
|
+
throw error;
|
|
20
|
+
clientId = await register(endpoints, CLIENT_NAME, opts.fetch);
|
|
21
|
+
start = await startDevice(endpoints, clientId, opts.fetch);
|
|
22
|
+
}
|
|
23
|
+
// The link comes from the server's reply. Print and open the parsed form, and open it only on the server's own origin.
|
|
24
|
+
const url = new URL(start.verification_uri_complete ?? start.verification_uri);
|
|
25
|
+
opts.print(`Open this link on any device (your phone works), then link your phone:\n\n ${plain(url.href)}\n\nThe page should show the code ${plain(start.user_code)}. Waiting...`);
|
|
26
|
+
if (!opts.noBrowser && url.origin === new URL(opts.server).origin)
|
|
27
|
+
(opts.openUrl ?? openBrowser)(url.href);
|
|
28
|
+
const tokens = await pollDevice(endpoints, clientId, start, { fetch: opts.fetch, sleep: opts.sleep, now: opts.now });
|
|
29
|
+
// Save under the lock, so a refresh running in an agent cannot interleave with it.
|
|
30
|
+
const previous = await opts.store.withLock(async () => {
|
|
31
|
+
const before = await opts.store.read();
|
|
32
|
+
await opts.store.write(savedFrom({ server: opts.server, client_id: clientId }, tokens, (opts.now ?? Date.now)()));
|
|
33
|
+
return before;
|
|
34
|
+
});
|
|
35
|
+
// The old login is replaced, so revoke it; best effort, since the new one already works.
|
|
36
|
+
if (previous) {
|
|
37
|
+
await revoke(endpoints, previous.client_id, previous.refresh_token, opts.fetch).catch((error) => {
|
|
38
|
+
opts.print(`Could not revoke the previous link (${describeError(error)}); it expires on its own.`);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
opts.print('Linked. Your agent can use Courier by Autolane now.');
|
|
42
|
+
}
|
|
43
|
+
/** Best effort: on a machine with no browser nothing opens, and the printed link is the way in. */
|
|
44
|
+
export function openBrowser(url) {
|
|
45
|
+
const [command, args] = process.platform === 'darwin' ? ['open', [url]] : process.platform === 'win32' ? [`${process.env.SystemRoot ?? 'C:\\Windows'}\\explorer.exe`, [url]] : ['xdg-open', [url]];
|
|
46
|
+
const child = spawn(command, args, { stdio: 'ignore', detached: true });
|
|
47
|
+
child.on('error', () => { });
|
|
48
|
+
child.unref();
|
|
49
|
+
}
|
package/dist/oauth.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// The courier authorization server, over fetch: RFC 8414 discovery, RFC 7591 registration of a device-only
|
|
2
|
+
// public client (AUT-3861), the RFC 8628 device grant (contract A91), refresh with rotation, RFC 7009 revoke.
|
|
3
|
+
import { describeError, plain } from './errors.js';
|
|
4
|
+
/** No request may hang: a refresh stuck past the lock's stale limit would let a second process reuse the refresh token. */
|
|
5
|
+
const REQUEST_TIMEOUT_MS = 10_000;
|
|
6
|
+
const timeout = () => AbortSignal.timeout(REQUEST_TIMEOUT_MS);
|
|
7
|
+
/** Every request refuses redirects: the endpoints are fixed, and a redirect would carry the form body, or a bearer, elsewhere. */
|
|
8
|
+
const guard = () => ({ signal: timeout(), redirect: 'error' });
|
|
9
|
+
export const DEVICE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code';
|
|
10
|
+
export class OAuthError extends Error {
|
|
11
|
+
code;
|
|
12
|
+
status;
|
|
13
|
+
constructor(code, description, status,
|
|
14
|
+
/** The endpoint path, for an error the server gave no description of. */
|
|
15
|
+
from) {
|
|
16
|
+
super(description ? `${code}: ${description}` : from ? `${code} from ${from}` : code);
|
|
17
|
+
this.code = code;
|
|
18
|
+
this.status = status;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
const ENDPOINT_KEYS = ['registration_endpoint', 'device_authorization_endpoint', 'token_endpoint', 'revocation_endpoint'];
|
|
22
|
+
async function json(res, url) {
|
|
23
|
+
const parsed = await res.json().catch(() => null);
|
|
24
|
+
// A body that is not an object (null, a number) is no better than none.
|
|
25
|
+
const body = (typeof parsed === 'object' && parsed !== null ? parsed : {});
|
|
26
|
+
if (!res.ok) {
|
|
27
|
+
const description = typeof body.error_description === 'string' ? body.error_description : undefined;
|
|
28
|
+
throw new OAuthError(typeof body.error === 'string' ? body.error : `http_${res.status}`, description, res.status, new URL(url).pathname);
|
|
29
|
+
}
|
|
30
|
+
return body;
|
|
31
|
+
}
|
|
32
|
+
/** A 200 that is not a token set must not reach the saved file, where the missing fields would drop the refresh token. */
|
|
33
|
+
async function tokenSet(res, url) {
|
|
34
|
+
const body = await json(res, url);
|
|
35
|
+
if (typeof body.access_token !== 'string' || typeof body.refresh_token !== 'string' || typeof body.expires_in !== 'number' || !Number.isFinite(body.expires_in)) {
|
|
36
|
+
throw new OAuthError('invalid_response', 'The token response is missing a field', 502);
|
|
37
|
+
}
|
|
38
|
+
return body;
|
|
39
|
+
}
|
|
40
|
+
const form = (fields) => ({
|
|
41
|
+
method: 'POST',
|
|
42
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
43
|
+
body: new URLSearchParams(fields).toString(),
|
|
44
|
+
...guard(),
|
|
45
|
+
});
|
|
46
|
+
/** RFC 8414 section 3.1: the issuer is the server URL (contract A97), so its path goes after the well-known prefix. */
|
|
47
|
+
export async function discover(server, f = fetch) {
|
|
48
|
+
const url = new URL(server);
|
|
49
|
+
const metadataUrl = `${url.origin}/.well-known/oauth-authorization-server${url.pathname.replace(/\/$/, '')}`;
|
|
50
|
+
const meta = await json(await f(metadataUrl, guard()), metadataUrl);
|
|
51
|
+
for (const key of ENDPOINT_KEYS) {
|
|
52
|
+
if (typeof meta[key] !== 'string')
|
|
53
|
+
throw new Error(`${server} does not offer ${key}; is it a Courier MCP URL?`);
|
|
54
|
+
}
|
|
55
|
+
// A metadata document is trusted only if it names this server and keeps every endpoint on its origin.
|
|
56
|
+
if (meta.issuer !== server)
|
|
57
|
+
throw new Error(`${server} metadata has the wrong issuer ${plain(String(meta.issuer))}; is it a Courier MCP URL?`);
|
|
58
|
+
for (const key of ENDPOINT_KEYS) {
|
|
59
|
+
if (new URL(meta[key]).origin !== url.origin)
|
|
60
|
+
throw new Error(`${server} metadata puts ${key} on another origin: ${plain(meta[key])}`);
|
|
61
|
+
}
|
|
62
|
+
return meta;
|
|
63
|
+
}
|
|
64
|
+
/** A client with no redirect URI links only through the device grant (AUT-3861). */
|
|
65
|
+
export async function register(endpoints, clientName, f = fetch) {
|
|
66
|
+
const res = await f(endpoints.registration_endpoint, {
|
|
67
|
+
method: 'POST',
|
|
68
|
+
headers: { 'content-type': 'application/json' },
|
|
69
|
+
body: JSON.stringify({ client_name: clientName, redirect_uris: [], grant_types: [DEVICE_GRANT, 'refresh_token'], token_endpoint_auth_method: 'none' }),
|
|
70
|
+
...guard(),
|
|
71
|
+
});
|
|
72
|
+
const { client_id } = await json(res, endpoints.registration_endpoint);
|
|
73
|
+
if (typeof client_id !== 'string')
|
|
74
|
+
throw new OAuthError('invalid_response', 'The registration response has no client_id', 502);
|
|
75
|
+
return client_id;
|
|
76
|
+
}
|
|
77
|
+
export async function startDevice(endpoints, clientId, f = fetch) {
|
|
78
|
+
const reply = await json(await f(endpoints.device_authorization_endpoint, form({ client_id: clientId })), endpoints.device_authorization_endpoint);
|
|
79
|
+
const text = (value) => typeof value === 'string';
|
|
80
|
+
if (!text(reply.device_code) || !text(reply.user_code) || !text(reply.verification_uri) || typeof reply.expires_in !== 'number' || !Number.isFinite(reply.expires_in) || (reply.interval !== undefined && typeof reply.interval !== 'number')) {
|
|
81
|
+
throw new OAuthError('invalid_response', 'The device authorization response is malformed', 502);
|
|
82
|
+
}
|
|
83
|
+
return reply;
|
|
84
|
+
}
|
|
85
|
+
/** A request that did not complete: a timeout, or fetch's "fetch failed" (its reason is in `cause`). Any other error is a bug, and stops the poll. */
|
|
86
|
+
const isTransient = (error) => error instanceof Error && (error.name === 'TimeoutError' || error.name === 'AbortError' || (error instanceof TypeError && error.message === 'fetch failed'));
|
|
87
|
+
/** RFC 8628 sections 3.4 and 3.5: wait `interval` seconds between polls, five more after each slow_down, until the user links or the code expires. */
|
|
88
|
+
export async function pollDevice(endpoints, clientId, start, opts = {}) {
|
|
89
|
+
const f = opts.fetch ?? fetch;
|
|
90
|
+
const sleep = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
91
|
+
const now = opts.now ?? Date.now;
|
|
92
|
+
// The server still accepts an approval for 30 seconds after expires_in, so the local deadline does too.
|
|
93
|
+
const deadline = now() + start.expires_in * 1000 + 30_000;
|
|
94
|
+
let interval = start.interval ?? 5;
|
|
95
|
+
let lastError;
|
|
96
|
+
for (;;) {
|
|
97
|
+
await sleep(interval * 1000);
|
|
98
|
+
if (now() > deadline) {
|
|
99
|
+
const last = lastError === undefined ? '' : ` (last error: ${describeError(lastError)})`;
|
|
100
|
+
throw new OAuthError('expired_token', `The link expired before your phone was linked; run login again${last}`, 400);
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
return await tokenSet(await f(endpoints.token_endpoint, form({ grant_type: DEVICE_GRANT, device_code: start.device_code, client_id: clientId })), endpoints.token_endpoint);
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
if (error instanceof OAuthError && error.code === 'authorization_pending')
|
|
107
|
+
continue;
|
|
108
|
+
// RFC 8628 section 3.5: a slow_down, a dropped connection, a timeout or a 5xx all mean "wait longer and ask again".
|
|
109
|
+
if ((error instanceof OAuthError && (error.code === 'slow_down' || (error.status >= 500 && error.code !== 'invalid_response'))) || isTransient(error)) {
|
|
110
|
+
if (!(error instanceof OAuthError && error.code === 'slow_down'))
|
|
111
|
+
lastError = error;
|
|
112
|
+
interval += 5;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
export async function refresh(endpoints, clientId, refreshToken, f = fetch) {
|
|
120
|
+
return tokenSet(await f(endpoints.token_endpoint, form({ grant_type: 'refresh_token', refresh_token: refreshToken, client_id: clientId })), endpoints.token_endpoint);
|
|
121
|
+
}
|
|
122
|
+
export async function revoke(endpoints, clientId, token, f = fetch) {
|
|
123
|
+
const res = await f(endpoints.revocation_endpoint, form({ token, token_type_hint: 'refresh_token', client_id: clientId }));
|
|
124
|
+
if (!res.ok)
|
|
125
|
+
throw new OAuthError(`http_${res.status}`, undefined, res.status, new URL(endpoints.revocation_endpoint).pathname);
|
|
126
|
+
}
|
package/dist/proxy.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// The stdio MCP server an agent runs, in front of /courier-mcp (spec section 4.3). It forwards tools and
|
|
2
|
+
// resources, everything the courier server offers, sending the session's bearer token on every request.
|
|
3
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
4
|
+
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
|
5
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
6
|
+
import { CallToolRequestSchema, McpError, ListResourceTemplatesRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
7
|
+
import { RELINK, VERSION } from './config.js';
|
|
8
|
+
import { describeError } from './errors.js';
|
|
9
|
+
import { NotLinkedError } from './session.js';
|
|
10
|
+
/** fetch with the bearer token; a 401 forces one refresh and resends (MCP request bodies are strings, so they can go twice). */
|
|
11
|
+
export function bearerFetch(session, base = fetch) {
|
|
12
|
+
return async (input, init) => {
|
|
13
|
+
const send = async (force) => {
|
|
14
|
+
const headers = new Headers(init?.headers);
|
|
15
|
+
headers.set('authorization', `Bearer ${await session.accessToken({ force })}`);
|
|
16
|
+
return base(input, { ...init, headers });
|
|
17
|
+
};
|
|
18
|
+
const first = await send(false);
|
|
19
|
+
if (first.status !== 401)
|
|
20
|
+
return first;
|
|
21
|
+
await first.body?.cancel();
|
|
22
|
+
return send(true);
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
/** Shown instead of the courier tools when the login is gone, so the agent can tell the user how to link. */
|
|
26
|
+
const LOGIN_TOOL = 'autolane_courier_login_needed';
|
|
27
|
+
const loginNeeded = `${RELINK}, then restart your agent.`;
|
|
28
|
+
/** One line, no token: only the error's message, newlines folded. */
|
|
29
|
+
const reportLine = (error) => `courier-mcp: ${describeError(error).replace(/\s+/g, ' ')}`;
|
|
30
|
+
/** The agent sees only an error's message, so a failure that keeps its reason in `cause` is rethrown with it spelled out. */
|
|
31
|
+
async function withCause(run) {
|
|
32
|
+
try {
|
|
33
|
+
return await run();
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
const text = describeError(error);
|
|
37
|
+
if (error instanceof NotLinkedError || error instanceof McpError || !(error instanceof Error) || text === error.message)
|
|
38
|
+
throw error;
|
|
39
|
+
throw new Error(text);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export function createProxy(opts) {
|
|
43
|
+
const write = opts.log ?? ((line) => console.error(line));
|
|
44
|
+
let closed = false;
|
|
45
|
+
// Closing aborts the upstream stream, and that abort is not worth a line.
|
|
46
|
+
const log = (line) => {
|
|
47
|
+
if (!closed)
|
|
48
|
+
write(line);
|
|
49
|
+
};
|
|
50
|
+
let remote = null;
|
|
51
|
+
/** Connects on first use, so the agent's handshake never waits on the network; a failed connect is retried next time. */
|
|
52
|
+
const connected = () => {
|
|
53
|
+
remote ??= (async () => {
|
|
54
|
+
const client = new Client({ name: 'autolane-courier-mcp', version: VERSION });
|
|
55
|
+
client.onerror = (error) => log(reportLine(error));
|
|
56
|
+
await client.connect(new StreamableHTTPClientTransport(new URL(opts.server), { fetch: bearerFetch(opts.session, opts.fetch) }));
|
|
57
|
+
return client;
|
|
58
|
+
})().catch((error) => {
|
|
59
|
+
remote = null;
|
|
60
|
+
throw error;
|
|
61
|
+
});
|
|
62
|
+
return remote;
|
|
63
|
+
};
|
|
64
|
+
const proxy = new Server({ name: 'autolane-courier', version: VERSION }, { capabilities: { tools: {}, resources: {} } });
|
|
65
|
+
proxy.onerror = (error) => log(reportLine(error));
|
|
66
|
+
proxy.onclose = () => {
|
|
67
|
+
closed = true;
|
|
68
|
+
const upstream = remote;
|
|
69
|
+
remote = null;
|
|
70
|
+
void upstream?.then((client) => client.close(), () => { });
|
|
71
|
+
};
|
|
72
|
+
proxy.setRequestHandler(ListToolsRequestSchema, async (req, extra) => {
|
|
73
|
+
try {
|
|
74
|
+
return await withCause(async () => (await connected()).listTools(req.params, { signal: extra.signal }));
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
if (error instanceof NotLinkedError)
|
|
78
|
+
return { tools: [{ name: LOGIN_TOOL, description: loginNeeded, inputSchema: { type: 'object', properties: {} } }] };
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
proxy.setRequestHandler(CallToolRequestSchema, async (req, extra) => {
|
|
83
|
+
// The agent shows a tool error to the user, so the relink line reaches them.
|
|
84
|
+
if (req.params.name === LOGIN_TOOL)
|
|
85
|
+
return { isError: true, content: [{ type: 'text', text: loginNeeded }] };
|
|
86
|
+
try {
|
|
87
|
+
return (await withCause(async () => (await connected()).callTool(req.params, undefined, { signal: extra.signal })));
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
if (error instanceof NotLinkedError)
|
|
91
|
+
return { isError: true, content: [{ type: 'text', text: error.message }] };
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
proxy.setRequestHandler(ListResourcesRequestSchema, async (req, extra) => withCause(async () => (await connected()).listResources(req.params, { signal: extra.signal })));
|
|
96
|
+
proxy.setRequestHandler(ListResourceTemplatesRequestSchema, async (req, extra) => withCause(async () => (await connected()).listResourceTemplates(req.params, { signal: extra.signal })));
|
|
97
|
+
proxy.setRequestHandler(ReadResourceRequestSchema, async (req, extra) => withCause(async () => (await connected()).readResource(req.params, { signal: extra.signal })));
|
|
98
|
+
return proxy;
|
|
99
|
+
}
|
package/dist/session.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// The access token to send. Near expiry, or after a 401, it refreshes under the store's lock, re-reading the file
|
|
2
|
+
// inside it: a second agent that waited uses the token the first just rotated, since reusing the old refresh token
|
|
3
|
+
// would make the server revoke the whole family (no grace window).
|
|
4
|
+
import { RELINK } from './config.js';
|
|
5
|
+
import { OAuthError, refresh } from './oauth.js';
|
|
6
|
+
/** Refresh when less than this is left on the access token. */
|
|
7
|
+
const REFRESH_EARLY_MS = 60_000;
|
|
8
|
+
export class NotLinkedError extends Error {
|
|
9
|
+
constructor() {
|
|
10
|
+
super(RELINK);
|
|
11
|
+
this.name = 'NotLinkedError';
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export function savedFrom(base, tokens, now) {
|
|
15
|
+
return { server: base.server, client_id: base.client_id, access_token: tokens.access_token, refresh_token: tokens.refresh_token, access_expires_at: now + tokens.expires_in * 1000 };
|
|
16
|
+
}
|
|
17
|
+
export function createSession(opts) {
|
|
18
|
+
const now = opts.now ?? Date.now;
|
|
19
|
+
return {
|
|
20
|
+
async accessToken({ force = false } = {}) {
|
|
21
|
+
const seen = await opts.store.read();
|
|
22
|
+
if (!seen)
|
|
23
|
+
throw new NotLinkedError();
|
|
24
|
+
if (!force && seen.access_expires_at - now() > REFRESH_EARLY_MS)
|
|
25
|
+
return seen.access_token;
|
|
26
|
+
// Look the endpoints up first, so only the refresh itself runs while the lock is held.
|
|
27
|
+
const endpoints = await opts.endpoints();
|
|
28
|
+
return opts.store.withLock(async () => {
|
|
29
|
+
const current = await opts.store.read();
|
|
30
|
+
if (!current)
|
|
31
|
+
throw new NotLinkedError();
|
|
32
|
+
if (current.refresh_token !== seen.refresh_token)
|
|
33
|
+
return current.access_token;
|
|
34
|
+
let tokens;
|
|
35
|
+
try {
|
|
36
|
+
tokens = await refresh(endpoints, current.client_id, current.refresh_token, opts.fetch);
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
// invalid_grant: the refresh token is spent or revoked. invalid_client: the server forgot this client. Both need a new login.
|
|
40
|
+
if (error instanceof OAuthError && (error.code === 'invalid_grant' || error.code === 'invalid_client'))
|
|
41
|
+
throw new NotLinkedError();
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
await opts.store.write(savedFrom(current, tokens, now()));
|
|
45
|
+
return tokens.access_token;
|
|
46
|
+
});
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
package/dist/store.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// The login on disk (spec section 4.4): one JSON file, 0600 in a 0700 directory, replaced by rename.
|
|
2
|
+
// The lock file serializes refreshes: the server rotates refresh tokens and revokes the whole family
|
|
3
|
+
// when an old one is reused, so two agents sharing this file must never refresh at once.
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { chmod, mkdir, open, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
const isSaved = (value) => {
|
|
8
|
+
const v = value;
|
|
9
|
+
return typeof v === 'object' && v !== null && typeof v.server === 'string' && typeof v.client_id === 'string' && typeof v.access_token === 'string' && typeof v.refresh_token === 'string' && typeof v.access_expires_at === 'number';
|
|
10
|
+
};
|
|
11
|
+
const isMissing = (error) => error.code === 'ENOENT';
|
|
12
|
+
export function fileStore(dir, server, opts = {}) {
|
|
13
|
+
const { staleMs = 30_000, waitMs = 30_000, pollMs = 100 } = opts;
|
|
14
|
+
const path = join(dir, 'courier-mcp.json');
|
|
15
|
+
const lockPath = join(dir, 'courier-mcp.lock');
|
|
16
|
+
const ensureDir = async () => {
|
|
17
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
18
|
+
await chmod(dir, 0o700);
|
|
19
|
+
};
|
|
20
|
+
return {
|
|
21
|
+
path,
|
|
22
|
+
async read() {
|
|
23
|
+
let text;
|
|
24
|
+
try {
|
|
25
|
+
text = await readFile(path, 'utf8');
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
if (isMissing(error))
|
|
29
|
+
return null;
|
|
30
|
+
throw error;
|
|
31
|
+
}
|
|
32
|
+
// A damaged file reads as not linked, so login can write over it. The parse error is dropped: V8 quotes the file in it.
|
|
33
|
+
const saved = (() => {
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(text);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
})();
|
|
41
|
+
return isSaved(saved) && saved.server === server ? saved : null;
|
|
42
|
+
},
|
|
43
|
+
async write(saved) {
|
|
44
|
+
await ensureDir();
|
|
45
|
+
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
46
|
+
try {
|
|
47
|
+
await writeFile(tmp, `${JSON.stringify(saved, null, 2)}\n`, { mode: 0o600 });
|
|
48
|
+
await rename(tmp, path);
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
await rm(tmp, { force: true });
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
async remove() {
|
|
56
|
+
await rm(path, { force: true });
|
|
57
|
+
},
|
|
58
|
+
async withLock(fn) {
|
|
59
|
+
await ensureDir();
|
|
60
|
+
const deadline = Date.now() + waitMs;
|
|
61
|
+
const mine = `${process.pid} ${randomUUID()}\n`;
|
|
62
|
+
for (;;) {
|
|
63
|
+
try {
|
|
64
|
+
const handle = await open(lockPath, 'wx', 0o600);
|
|
65
|
+
try {
|
|
66
|
+
await handle.writeFile(mine);
|
|
67
|
+
await handle.close();
|
|
68
|
+
}
|
|
69
|
+
catch (writeError) {
|
|
70
|
+
// We created the lock but could not finish it: do not strand it for the stale limit.
|
|
71
|
+
await handle.close().catch(() => { });
|
|
72
|
+
await rm(lockPath, { force: true });
|
|
73
|
+
throw writeError;
|
|
74
|
+
}
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
if (error.code !== 'EEXIST')
|
|
79
|
+
throw error;
|
|
80
|
+
const age = await stat(lockPath).then((s) => Date.now() - s.mtimeMs, () => 0);
|
|
81
|
+
if (age > staleMs) {
|
|
82
|
+
await rm(lockPath, { force: true });
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (Date.now() > deadline)
|
|
86
|
+
throw new Error(`Another courier-mcp process holds ${lockPath}; try again in a moment`);
|
|
87
|
+
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
return await fn();
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
// Another process may have taken a lock we held too long (stale takeover); never remove theirs.
|
|
95
|
+
// debt: two waiters can both judge the same lock stale and both take it, upgrade to an atomic rename takeover if that is seen.
|
|
96
|
+
if ((await readFile(lockPath, 'utf8').catch(() => null)) === mine)
|
|
97
|
+
await rm(lockPath, { force: true });
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,42 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@autolane/courier-mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"mcpName": "com.goautolane/courier",
|
|
5
|
+
"description": "Courier by Autolane MCP for agents on any machine: a local stdio server that links through the device grant",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"bin": {
|
|
9
|
+
"courier-mcp": "dist/bin.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=20"
|
|
16
|
+
},
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/Autolane/courier-mcp.git"
|
|
20
|
+
},
|
|
21
|
+
"homepage": "https://github.com/Autolane/courier-mcp#readme",
|
|
22
|
+
"keywords": [
|
|
23
|
+
"mcp",
|
|
24
|
+
"autolane",
|
|
25
|
+
"courier",
|
|
26
|
+
"delivery"
|
|
27
|
+
],
|
|
28
|
+
"scripts": {
|
|
29
|
+
"build": "tsc -p tsconfig.build.json",
|
|
30
|
+
"typecheck": "tsc --noEmit",
|
|
31
|
+
"test": "vitest run",
|
|
32
|
+
"prepublishOnly": "npm run build"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@modelcontextprotocol/sdk": "^1.30.0"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@types/node": "^20.19.43",
|
|
39
|
+
"typescript": "^5.9.3",
|
|
40
|
+
"vitest": "^4.1.11"
|
|
41
|
+
}
|
|
42
|
+
}
|