rivian-mcp 1.0.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Patrick Heneise
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 ADDED
@@ -0,0 +1,78 @@
1
+ # Rivian MCP
2
+
3
+ Read-only [MCP server](https://modelcontextprotocol.io) for the unofficial Rivian GraphQL API. Check your vehicle's battery, range, OTA updates, charging status, and more — right from Claude.
4
+
5
+ **Strictly read-only** — no vehicle commands, no settings changes.
6
+
7
+ ## Setup
8
+
9
+ ```bash
10
+ claude mcp add rivian \
11
+ -e RIVIAN_EMAIL=your@email.com \
12
+ -e RIVIAN_PASSWORD=your-password \
13
+ -- npx rivian-mcp
14
+ ```
15
+
16
+ <details>
17
+ <summary>Manual config</summary>
18
+
19
+ Add to `~/.claude.json` or your project's `.mcp.json`:
20
+
21
+ ```json
22
+ {
23
+ "mcpServers": {
24
+ "rivian": {
25
+ "command": "npx",
26
+ "args": ["rivian-mcp"],
27
+ "env": {
28
+ "RIVIAN_EMAIL": "your@email.com",
29
+ "RIVIAN_PASSWORD": "your-password"
30
+ }
31
+ }
32
+ }
33
+ }
34
+ ```
35
+
36
+ </details>
37
+
38
+ ### First-time login
39
+
40
+ Rivian requires 2FA on every new login:
41
+
42
+ 1. Ask Claude: **"Log in to Rivian"**
43
+ 2. Rivian sends a verification code to your phone/email
44
+ 3. Tell Claude the code: **"The code is 123456"**
45
+
46
+ Your session is saved to `~/.rivian-mcp/session.json` and reused automatically until it expires (7 days).
47
+
48
+ ## What you can ask
49
+
50
+ - "What's my battery level?"
51
+ - "Is there a software update available?"
52
+ - "Are all the doors locked?"
53
+ - "Show me the full vehicle status"
54
+ - "Who has keys to my R1S?"
55
+ - "Am I currently charging?"
56
+
57
+ ## Tools
58
+
59
+ | Tool | What it does |
60
+ |---|---|
61
+ | `rivian_login` | Start sign-in (triggers verification code) |
62
+ | `rivian_submit_otp` | Complete sign-in with the verification code |
63
+ | `rivian_get_user_info` | Your account, vehicles, and software versions |
64
+ | `rivian_get_vehicle_state` | Live status — battery, doors, tires, location, climate, OTA |
65
+ | `rivian_get_ota_status` | Current and available software versions |
66
+ | `rivian_get_charging_session` | Active charging session details |
67
+ | `rivian_get_drivers_and_keys` | Drivers and their phone keys / key fobs |
68
+
69
+ ## Requirements
70
+
71
+ - Node.js 24+
72
+ - A Rivian account with a vehicle
73
+
74
+ ## References
75
+
76
+ - [Unofficial Rivian API Docs](https://rivian-api.kaedenb.org/app/)
77
+ - [rivian-python-client](https://github.com/bretterer/rivian-python-client) — Python client this is based on
78
+ - [home-assistant-rivian](https://github.com/bretterer/home-assistant-rivian) — Home Assistant integration
package/lib/rivian.js ADDED
@@ -0,0 +1,379 @@
1
+ /**
2
+ * Rivian API — READ ONLY
3
+ *
4
+ * Based on bretterer/rivian-python-client and rivian-api.kaedenb.org docs.
5
+ * No write/post/patch mutations — only auth and queries.
6
+ */
7
+
8
+ const GRAPHQL_GATEWAY = 'https://rivian.com/api/gql/gateway/graphql';
9
+ const GRAPHQL_CHARGING = 'https://rivian.com/api/gql/chrg/user/graphql';
10
+ const APOLLO_CLIENT_NAME = 'com.rivian.ios.consumer-apollo-ios';
11
+
12
+ const BASE_HEADERS = {
13
+ 'User-Agent': 'RivianApp/707 CFNetwork/1237 Darwin/20.4.0',
14
+ 'Accept': 'application/json',
15
+ 'Content-Type': 'application/json',
16
+ 'Apollographql-Client-Name': APOLLO_CLIENT_NAME,
17
+ };
18
+
19
+ // ── Session state ───────────────────────────────────────────────────
20
+
21
+ let csrfToken = '';
22
+ let appSessionToken = '';
23
+ let accessToken = '';
24
+ let refreshToken = '';
25
+ let userSessionToken = '';
26
+ let otpToken = '';
27
+
28
+ export const needsOtp = () => !!otpToken;
29
+ export const isAuthenticated = () => !!accessToken;
30
+
31
+ // ── Auth ────────────────────────────────────────────────────────────
32
+
33
+ export async function createCsrfToken() {
34
+ const data = await gql(GRAPHQL_GATEWAY, {
35
+ operationName: 'CreateCSRFToken',
36
+ query: `mutation CreateCSRFToken {
37
+ createCsrfToken {
38
+ __typename
39
+ csrfToken
40
+ appSessionToken
41
+ }
42
+ }`,
43
+ variables: null,
44
+ });
45
+ csrfToken = data.createCsrfToken.csrfToken;
46
+ appSessionToken = data.createCsrfToken.appSessionToken;
47
+ }
48
+
49
+ export async function login(email, password) {
50
+ const data = await gql(
51
+ GRAPHQL_GATEWAY,
52
+ {
53
+ operationName: 'Login',
54
+ query: `mutation Login($email: String!, $password: String!) {
55
+ login(email: $email, password: $password) {
56
+ __typename
57
+ ... on MobileLoginResponse {
58
+ __typename
59
+ accessToken
60
+ refreshToken
61
+ userSessionToken
62
+ }
63
+ ... on MobileMFALoginResponse {
64
+ __typename
65
+ otpToken
66
+ }
67
+ }
68
+ }`,
69
+ variables: { email, password },
70
+ },
71
+ { 'Csrf-Token': csrfToken, 'A-Sess': appSessionToken },
72
+ );
73
+
74
+ if (data.login.otpToken) {
75
+ otpToken = data.login.otpToken;
76
+ return { mfa: true };
77
+ }
78
+
79
+ accessToken = data.login.accessToken;
80
+ refreshToken = data.login.refreshToken;
81
+ userSessionToken = data.login.userSessionToken;
82
+ return { mfa: false };
83
+ }
84
+
85
+ export async function validateOtp(email, otpCode) {
86
+ const data = await gql(
87
+ GRAPHQL_GATEWAY,
88
+ {
89
+ operationName: 'LoginWithOTP',
90
+ query: `mutation LoginWithOTP($email: String!, $otpCode: String!, $otpToken: String!) {
91
+ loginWithOTP(email: $email, otpCode: $otpCode, otpToken: $otpToken) {
92
+ __typename
93
+ ... on MobileLoginResponse {
94
+ __typename
95
+ accessToken
96
+ refreshToken
97
+ userSessionToken
98
+ }
99
+ }
100
+ }`,
101
+ variables: { email, otpCode, otpToken },
102
+ },
103
+ { 'Csrf-Token': csrfToken, 'A-Sess': appSessionToken },
104
+ );
105
+
106
+ accessToken = data.loginWithOTP.accessToken;
107
+ refreshToken = data.loginWithOTP.refreshToken;
108
+ userSessionToken = data.loginWithOTP.userSessionToken;
109
+ otpToken = '';
110
+ }
111
+
112
+ // ── Read-only queries ───────────────────────────────────────────────
113
+
114
+ export async function getUserInfo() {
115
+ const body = {
116
+ operationName: 'getUserInfo',
117
+ query: `query getUserInfo {
118
+ currentUser {
119
+ __typename
120
+ id
121
+ firstName
122
+ lastName
123
+ email
124
+ vehicles {
125
+ id
126
+ vin
127
+ name
128
+ roles
129
+ state
130
+ createdAt
131
+ updatedAt
132
+ vas { __typename vasVehicleId vehiclePublicKey }
133
+ vehicle {
134
+ __typename
135
+ id
136
+ vin
137
+ modelYear
138
+ make
139
+ model
140
+ expectedBuildDate
141
+ plannedBuildDate
142
+ otaEarlyAccessStatus
143
+ currentOTAUpdateDetails { url version locale }
144
+ availableOTAUpdateDetails { url version locale }
145
+ vehicleState {
146
+ supportedFeatures { __typename name status }
147
+ }
148
+ }
149
+ }
150
+ registrationChannels { type }
151
+ }
152
+ }`,
153
+ variables: null,
154
+ };
155
+
156
+ return (await gql(GRAPHQL_GATEWAY, body, authHeaders())).currentUser;
157
+ }
158
+
159
+ export async function getVehicleState(vehicleId, properties) {
160
+ const props = properties || DEFAULT_VEHICLE_STATE_PROPERTIES;
161
+ const fragment = [...props]
162
+ .map((p) => `${p} ${TEMPLATE_MAP[p] || VALUE_TEMPLATE}`)
163
+ .join('\n ');
164
+
165
+ const body = {
166
+ operationName: 'GetVehicleState',
167
+ query: `query GetVehicleState($vehicleID: String!) {
168
+ vehicleState(id: $vehicleID) {
169
+ ${fragment}
170
+ }
171
+ }`,
172
+ variables: { vehicleID: vehicleId },
173
+ };
174
+
175
+ return (await gql(GRAPHQL_GATEWAY, body, authHeaders())).vehicleState;
176
+ }
177
+
178
+ export async function getOTAUpdateDetails(vehicleId) {
179
+ const body = {
180
+ operationName: 'getOTAUpdateDetails',
181
+ query: `query getOTAUpdateDetails($vehicleId: String!) {
182
+ getVehicle(id: $vehicleId) {
183
+ availableOTAUpdateDetails { url version locale }
184
+ currentOTAUpdateDetails { url version locale }
185
+ }
186
+ }`,
187
+ variables: { vehicleId },
188
+ };
189
+
190
+ return (await gql(GRAPHQL_GATEWAY, body, authHeaders())).getVehicle;
191
+ }
192
+
193
+ export async function getLiveChargingSession(vehicleId) {
194
+ const body = {
195
+ operationName: 'getLiveSessionData',
196
+ query: `query getLiveSessionData($vehicleId: ID!) {
197
+ getLiveSessionData(vehicleId: $vehicleId) {
198
+ __typename
199
+ chargerId
200
+ current { __typename value updatedAt }
201
+ currentCurrency
202
+ currentMiles { __typename value updatedAt }
203
+ currentPrice
204
+ isFreeSession
205
+ isRivianCharger
206
+ kilometersChargedPerHour { __typename value updatedAt }
207
+ locationId
208
+ power { __typename value updatedAt }
209
+ rangeAddedThisSession { __typename value updatedAt }
210
+ soc { __typename value updatedAt }
211
+ startTime
212
+ timeElapsed
213
+ timeRemaining { __typename value updatedAt }
214
+ totalChargedEnergy { __typename value updatedAt }
215
+ vehicleChargerState { __typename value updatedAt }
216
+ }
217
+ }`,
218
+ variables: { vehicleId },
219
+ };
220
+
221
+ return (await gql(GRAPHQL_CHARGING, body, chargingHeaders())).getLiveSessionData;
222
+ }
223
+
224
+ export async function getDriversAndKeys(vehicleId) {
225
+ const body = {
226
+ operationName: 'DriversAndKeys',
227
+ query: `query DriversAndKeys($vehicleId: String) {
228
+ getVehicle(id: $vehicleId) {
229
+ __typename
230
+ id
231
+ vin
232
+ invitedUsers {
233
+ __typename
234
+ ... on ProvisionedUser {
235
+ firstName lastName email roles userId
236
+ devices { type mappedIdentityId id hrid deviceName isPaired isEnabled }
237
+ }
238
+ ... on UnprovisionedUser {
239
+ email inviteId status
240
+ }
241
+ }
242
+ }
243
+ }`,
244
+ variables: { vehicleId },
245
+ };
246
+
247
+ return (await gql(GRAPHQL_GATEWAY, body, authHeaders())).getVehicle;
248
+ }
249
+
250
+ // ── Session persistence ──────────────────────────────────────────────
251
+
252
+ export function exportSession() {
253
+ return {
254
+ csrfToken,
255
+ appSessionToken,
256
+ accessToken,
257
+ refreshToken,
258
+ userSessionToken,
259
+ otpToken,
260
+ needsOtp: !!otpToken,
261
+ authenticated: !!accessToken,
262
+ };
263
+ }
264
+
265
+ export function restoreSession(session) {
266
+ csrfToken = session.csrfToken || '';
267
+ appSessionToken = session.appSessionToken || '';
268
+ accessToken = session.accessToken || '';
269
+ refreshToken = session.refreshToken || '';
270
+ userSessionToken = session.userSessionToken || '';
271
+ otpToken = session.otpToken || '';
272
+ }
273
+
274
+ // ── Internals ───────────────────────────────────────────────────────
275
+
276
+ function authHeaders() {
277
+ return { 'A-Sess': appSessionToken, 'U-Sess': userSessionToken };
278
+ }
279
+
280
+ function chargingHeaders() {
281
+ return { 'U-Sess': userSessionToken };
282
+ }
283
+
284
+ async function gql(url, body, extraHeaders = {}) {
285
+ const res = await fetch(url, {
286
+ method: 'POST',
287
+ headers: {
288
+ ...BASE_HEADERS,
289
+ 'dc-cid': `m-ios-${crypto.randomUUID()}`,
290
+ ...extraHeaders,
291
+ },
292
+ body: JSON.stringify(body),
293
+ });
294
+
295
+ const json = await res.json();
296
+
297
+ if (json.errors?.length) {
298
+ const e = json.errors[0];
299
+ const msg = e.message || e.extensions?.code || 'Unknown GraphQL error';
300
+ const err = new Error(msg);
301
+ err.code = e.extensions?.code;
302
+ err.reason = e.extensions?.reason;
303
+ throw err;
304
+ }
305
+
306
+ if (!res.ok) {
307
+ throw new Error(`HTTP ${res.status}`);
308
+ }
309
+
310
+ return json.data;
311
+ }
312
+
313
+ // ── Vehicle state property templates ────────────────────────────────
314
+
315
+ const VALUE_TEMPLATE = '{ timeStamp value }';
316
+
317
+ const TEMPLATE_MAP = {
318
+ cloudConnection: '{ lastSync isOnline }',
319
+ gnssLocation: '{ latitude longitude timeStamp isAuthorized }',
320
+ gnssError: '{ timeStamp positionVertical positionHorizontal speed bearing }',
321
+ };
322
+
323
+ const DEFAULT_VEHICLE_STATE_PROPERTIES = new Set([
324
+ 'cloudConnection',
325
+ 'gnssLocation',
326
+ 'batteryLevel',
327
+ 'batteryLimit',
328
+ 'batteryCapacity',
329
+ 'distanceToEmpty',
330
+ 'vehicleMileage',
331
+ 'powerState',
332
+ 'chargerStatus',
333
+ 'chargerState',
334
+ 'chargePortState',
335
+ 'otaAvailableVersion',
336
+ 'otaAvailableVersionGitHash',
337
+ 'otaCurrentVersion',
338
+ 'otaCurrentVersionGitHash',
339
+ 'otaStatus',
340
+ 'otaInstallReady',
341
+ 'otaInstallProgress',
342
+ 'otaCurrentStatus',
343
+ 'otaDownloadProgress',
344
+ 'otaInstallType',
345
+ 'driveMode',
346
+ 'gearStatus',
347
+ 'tirePressureStatusFrontLeft',
348
+ 'tirePressureStatusFrontRight',
349
+ 'tirePressureStatusRearLeft',
350
+ 'tirePressureStatusRearRight',
351
+ 'doorFrontLeftClosed',
352
+ 'doorFrontRightClosed',
353
+ 'doorRearLeftClosed',
354
+ 'doorRearRightClosed',
355
+ 'doorFrontLeftLocked',
356
+ 'doorFrontRightLocked',
357
+ 'doorRearLeftLocked',
358
+ 'doorRearRightLocked',
359
+ 'closureFrunkClosed',
360
+ 'closureFrunkLocked',
361
+ 'closureLiftgateClosed',
362
+ 'closureLiftgateLocked',
363
+ 'closureTailgateClosed',
364
+ 'closureTailgateLocked',
365
+ 'closureTonneauClosed',
366
+ 'closureTonneauLocked',
367
+ 'windowFrontLeftClosed',
368
+ 'windowFrontRightClosed',
369
+ 'windowRearLeftClosed',
370
+ 'windowRearRightClosed',
371
+ 'cabinClimateInteriorTemperature',
372
+ 'cabinPreconditioningStatus',
373
+ 'defrostDefogStatus',
374
+ 'petModeStatus',
375
+ 'gearGuardLocked',
376
+ 'gearGuardVideoStatus',
377
+ 'timeToEndOfCharge',
378
+ 'remoteChargingAvailable',
379
+ ]);
package/mcp-server.js ADDED
@@ -0,0 +1,540 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
4
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
5
+ import { z } from 'zod';
6
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync, statSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import { homedir } from 'node:os';
9
+ import * as rivian from './lib/rivian.js';
10
+
11
+ const CONFIG_DIR = join(homedir(), '.rivian-mcp');
12
+ const SESSION_FILE = join(CONFIG_DIR, 'session.json');
13
+ const SESSION_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
14
+
15
+ // ── Session persistence ───────────────────────────────────────────────
16
+
17
+ function loadSession() {
18
+ if (!existsSync(SESSION_FILE)) return false;
19
+
20
+ try {
21
+ const st = statSync(SESSION_FILE);
22
+ if (st.mode & 0o077) {
23
+ console.error(
24
+ `[rivian-mcp] WARNING: ${SESSION_FILE} is readable by other users. Run: chmod 600 "${SESSION_FILE}"`,
25
+ );
26
+ }
27
+ } catch {}
28
+
29
+ const session = JSON.parse(readFileSync(SESSION_FILE, 'utf8'));
30
+
31
+ if (session.savedAt && Date.now() - session.savedAt > SESSION_MAX_AGE_MS) {
32
+ console.error('[rivian-mcp] Session expired. Please log in again.');
33
+ return false;
34
+ }
35
+
36
+ if (session.authenticated || session.needsOtp) {
37
+ rivian.restoreSession(session);
38
+ return session.authenticated ? true : 'needs_otp';
39
+ }
40
+
41
+ return false;
42
+ }
43
+
44
+ function saveSession() {
45
+ mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
46
+ chmodSync(CONFIG_DIR, 0o700);
47
+ const session = { ...rivian.exportSession(), savedAt: Date.now() };
48
+ writeFileSync(SESSION_FILE, JSON.stringify(session, null, 2), { mode: 0o600 });
49
+ chmodSync(SESSION_FILE, 0o600);
50
+ }
51
+
52
+ function requireAuth() {
53
+ if (!rivian.isAuthenticated()) {
54
+ throw new Error('Not logged in. Ask the user to log in to Rivian first.');
55
+ }
56
+ }
57
+
58
+ function text(str) {
59
+ return { content: [{ type: 'text', text: str }] };
60
+ }
61
+
62
+ // ── Formatting ────────────────────────────────────────────────────────
63
+
64
+ function formatUserInfo(user) {
65
+ const lines = [`${user.firstName} ${user.lastName} (${user.email})`, ''];
66
+
67
+ if (!user.vehicles?.length) {
68
+ lines.push('No vehicles on this account.');
69
+ return lines.join('\n');
70
+ }
71
+
72
+ for (const v of user.vehicles) {
73
+ const car = v.vehicle;
74
+ lines.push(v.name || car.model);
75
+ lines.push(` ${car.modelYear} ${car.make} ${car.model}`);
76
+ lines.push(` VIN: ${v.vin}`);
77
+ lines.push(` Vehicle ID: ${v.id}`);
78
+
79
+ if (car.otaEarlyAccessStatus) {
80
+ lines.push(` OTA early access: ${car.otaEarlyAccessStatus === 'OPTED_IN' ? 'Yes' : 'No'}`);
81
+ }
82
+ if (car.currentOTAUpdateDetails) {
83
+ lines.push(` Software: v${car.currentOTAUpdateDetails.version}`);
84
+ }
85
+ if (car.availableOTAUpdateDetails) {
86
+ lines.push(` Update available: v${car.availableOTAUpdateDetails.version}`);
87
+ lines.push(` Release notes: ${car.availableOTAUpdateDetails.url}`);
88
+ } else {
89
+ lines.push(' Software is up to date');
90
+ }
91
+ lines.push('');
92
+ }
93
+
94
+ return lines.join('\n').trim();
95
+ }
96
+
97
+ function formatVehicleState(state) {
98
+ const lines = [];
99
+ const printed = new Set();
100
+
101
+ function v(key) {
102
+ return state[key]?.value ?? null;
103
+ }
104
+
105
+ function print(label, key, suffix = '') {
106
+ if (!(key in state)) return;
107
+ printed.add(key);
108
+ const value = v(key);
109
+ if (value === null || value === undefined) return;
110
+ lines.push(` ${label}: ${value}${suffix}`);
111
+ }
112
+
113
+ // Battery & Range
114
+ if ('batteryLevel' in state || 'distanceToEmpty' in state) {
115
+ lines.push('Battery & Range');
116
+ print('Battery', 'batteryLevel', '%');
117
+ print('Charge limit', 'batteryLimit', '%');
118
+ print('Capacity', 'batteryCapacity');
119
+ print('Range', 'distanceToEmpty', ' miles');
120
+ print('Odometer', 'vehicleMileage', ' miles');
121
+ print('Power', 'powerState');
122
+ print('Time to full', 'timeToEndOfCharge', ' min');
123
+ print('Remote charging', 'remoteChargingAvailable');
124
+ lines.push('');
125
+ }
126
+
127
+ // Charging
128
+ if ('chargerStatus' in state || 'chargerState' in state) {
129
+ lines.push('Charging');
130
+ print('Charger status', 'chargerStatus');
131
+ print('Charger state', 'chargerState');
132
+ print('Charge port', 'chargePortState');
133
+ lines.push('');
134
+ }
135
+
136
+ // Doors — combine closed + locked per door
137
+ const doorPositions = ['FrontLeft', 'FrontRight', 'RearLeft', 'RearRight'];
138
+ const doorLines = [];
139
+ for (const pos of doorPositions) {
140
+ const closedKey = `door${pos}Closed`;
141
+ const lockedKey = `door${pos}Locked`;
142
+ if (closedKey in state || lockedKey in state) {
143
+ printed.add(closedKey);
144
+ printed.add(lockedKey);
145
+ const parts = [v(closedKey), v(lockedKey)].filter(Boolean);
146
+ const label = pos.replace(/([A-Z])/g, ' $1').trim().toLowerCase();
147
+ if (parts.length) doorLines.push(` ${label}: ${parts.join(', ')}`);
148
+ }
149
+ }
150
+ if (doorLines.length) {
151
+ lines.push('Doors');
152
+ lines.push(...doorLines);
153
+ lines.push('');
154
+ }
155
+
156
+ // Closures — frunk, liftgate, tailgate, tonneau
157
+ const closures = ['Frunk', 'Liftgate', 'Tailgate', 'Tonneau'];
158
+ const closureLines = [];
159
+ for (const name of closures) {
160
+ const closedKey = `closure${name}Closed`;
161
+ const lockedKey = `closure${name}Locked`;
162
+ if (closedKey in state || lockedKey in state) {
163
+ printed.add(closedKey);
164
+ printed.add(lockedKey);
165
+ const parts = [v(closedKey), v(lockedKey)].filter(Boolean);
166
+ if (parts.length) closureLines.push(` ${name.toLowerCase()}: ${parts.join(', ')}`);
167
+ }
168
+ }
169
+ if (closureLines.length) {
170
+ lines.push('Closures');
171
+ lines.push(...closureLines);
172
+ lines.push('');
173
+ }
174
+
175
+ // Windows
176
+ const windowLines = [];
177
+ for (const pos of doorPositions) {
178
+ const key = `window${pos}Closed`;
179
+ if (key in state) {
180
+ printed.add(key);
181
+ const value = v(key);
182
+ if (value) {
183
+ const label = pos.replace(/([A-Z])/g, ' $1').trim().toLowerCase();
184
+ windowLines.push(` ${label}: ${value}`);
185
+ }
186
+ }
187
+ }
188
+ if (windowLines.length) {
189
+ lines.push('Windows');
190
+ lines.push(...windowLines);
191
+ lines.push('');
192
+ }
193
+
194
+ // Climate
195
+ if ('cabinClimateInteriorTemperature' in state || 'cabinPreconditioningStatus' in state) {
196
+ lines.push('Climate');
197
+ if ('cabinClimateInteriorTemperature' in state) {
198
+ printed.add('cabinClimateInteriorTemperature');
199
+ const temp = v('cabinClimateInteriorTemperature');
200
+ if (temp) lines.push(` Cabin temp: ${temp}°`);
201
+ }
202
+ print('Preconditioning', 'cabinPreconditioningStatus');
203
+ print('Defrost/defog', 'defrostDefogStatus');
204
+ print('Pet mode', 'petModeStatus');
205
+ lines.push('');
206
+ }
207
+
208
+ // Tires
209
+ const tirePositions = { FrontLeft: 'front left', FrontRight: 'front right', RearLeft: 'rear left', RearRight: 'rear right' };
210
+ const tireLines = [];
211
+ for (const [pos, label] of Object.entries(tirePositions)) {
212
+ const key = `tirePressureStatus${pos}`;
213
+ if (key in state) {
214
+ printed.add(key);
215
+ const value = v(key);
216
+ if (value) tireLines.push(` ${label}: ${value}`);
217
+ }
218
+ }
219
+ if (tireLines.length) {
220
+ lines.push('Tire Pressure');
221
+ lines.push(...tireLines);
222
+ lines.push('');
223
+ }
224
+
225
+ // Software (OTA)
226
+ if ('otaCurrentVersion' in state || 'otaAvailableVersion' in state || 'otaStatus' in state) {
227
+ lines.push('Software');
228
+ print('Current version', 'otaCurrentVersion');
229
+ print('Available update', 'otaAvailableVersion');
230
+ print('Status', 'otaStatus');
231
+ print('Install status', 'otaCurrentStatus');
232
+ print('Install ready', 'otaInstallReady');
233
+ print('Install progress', 'otaInstallProgress', '%');
234
+ print('Download progress', 'otaDownloadProgress', '%');
235
+ print('Install type', 'otaInstallType');
236
+ print('Current hash', 'otaCurrentVersionGitHash');
237
+ print('Available hash', 'otaAvailableVersionGitHash');
238
+ lines.push('');
239
+ }
240
+
241
+ // Security
242
+ if ('gearGuardLocked' in state) {
243
+ lines.push('Security');
244
+ print('Gear Guard', 'gearGuardLocked');
245
+ print('Gear Guard video', 'gearGuardVideoStatus');
246
+ lines.push('');
247
+ }
248
+
249
+ // Drive
250
+ if ('driveMode' in state || 'gearStatus' in state) {
251
+ lines.push('Drive');
252
+ print('Drive mode', 'driveMode');
253
+ print('Gear', 'gearStatus');
254
+ lines.push('');
255
+ }
256
+
257
+ // Connection
258
+ if ('cloudConnection' in state) {
259
+ printed.add('cloudConnection');
260
+ const cc = state.cloudConnection;
261
+ const online = cc?.isOnline ? 'Online' : 'Offline';
262
+ const sync = cc?.lastSync ? ` (last sync: ${cc.lastSync})` : '';
263
+ lines.push('Connection');
264
+ lines.push(` Status: ${online}${sync}`);
265
+ lines.push('');
266
+ }
267
+
268
+ // Location
269
+ if ('gnssLocation' in state) {
270
+ printed.add('gnssLocation');
271
+ const loc = state.gnssLocation;
272
+ if (loc?.latitude && loc?.longitude) {
273
+ lines.push('Location');
274
+ lines.push(` ${loc.latitude}, ${loc.longitude}`);
275
+ lines.push('');
276
+ }
277
+ }
278
+
279
+ // Anything not already printed
280
+ const remaining = [];
281
+ for (const [key, entry] of Object.entries(state)) {
282
+ if (printed.has(key)) continue;
283
+ const value = entry?.value ?? entry;
284
+ if (value !== null && value !== undefined) {
285
+ remaining.push(` ${key}: ${value}`);
286
+ }
287
+ }
288
+ if (remaining.length) {
289
+ lines.push('Other');
290
+ lines.push(...remaining);
291
+ }
292
+
293
+ return lines.join('\n').trim();
294
+ }
295
+
296
+ function formatOTAStatus(ota) {
297
+ const lines = [];
298
+
299
+ if (ota.currentOTAUpdateDetails) {
300
+ lines.push(`Current software: v${ota.currentOTAUpdateDetails.version}`);
301
+ } else {
302
+ lines.push('Current software: unknown');
303
+ }
304
+
305
+ if (ota.availableOTAUpdateDetails) {
306
+ lines.push(`Update available: v${ota.availableOTAUpdateDetails.version}`);
307
+ if (ota.availableOTAUpdateDetails.url) {
308
+ lines.push(`Release notes: ${ota.availableOTAUpdateDetails.url}`);
309
+ }
310
+ } else {
311
+ lines.push('No update available — software is up to date.');
312
+ }
313
+
314
+ return lines.join('\n');
315
+ }
316
+
317
+ function formatChargingSession(data) {
318
+ if (!data) return 'No active charging session.';
319
+
320
+ const lines = ['Charging Session'];
321
+
322
+ const add = (label, entry, suffix = '') => {
323
+ const value = entry?.value;
324
+ if (value !== undefined && value !== null) lines.push(` ${label}: ${value}${suffix}`);
325
+ };
326
+
327
+ add('Battery', data.soc, '%');
328
+ add('Power', data.power, ' kW');
329
+ add('Range added', data.rangeAddedThisSession, ' miles');
330
+ add('Energy charged', data.totalChargedEnergy, ' kWh');
331
+ add('Current', data.current, ' A');
332
+ if (data.timeElapsed) lines.push(` Time elapsed: ${data.timeElapsed}`);
333
+ add('Time remaining', data.timeRemaining, ' min');
334
+
335
+ if (data.isRivianCharger) lines.push(' Network: Rivian Adventure Network');
336
+ if (data.isFreeSession) {
337
+ lines.push(' Cost: Free');
338
+ } else if (data.currentPrice) {
339
+ lines.push(` Cost so far: ${data.currentCurrency || '$'}${data.currentPrice}`);
340
+ }
341
+
342
+ add('Charger state', data.vehicleChargerState);
343
+
344
+ return lines.join('\n');
345
+ }
346
+
347
+ function formatDriversAndKeys(data) {
348
+ const lines = [];
349
+
350
+ if (data.vin) lines.push(`Vehicle: ${data.vin}`);
351
+
352
+ if (!data.invitedUsers?.length) {
353
+ lines.push('No drivers or keys found.');
354
+ return lines.join('\n');
355
+ }
356
+
357
+ lines.push('');
358
+ for (const user of data.invitedUsers) {
359
+ if (user.firstName) {
360
+ lines.push(`${user.firstName} ${user.lastName} (${user.email})`);
361
+ if (user.roles?.length) lines.push(` Roles: ${user.roles.join(', ')}`);
362
+
363
+ if (user.devices?.length) {
364
+ for (const d of user.devices) {
365
+ const name = d.deviceName || d.type;
366
+ const status = [
367
+ d.isPaired ? 'paired' : 'not paired',
368
+ d.isEnabled ? 'enabled' : 'disabled',
369
+ ].join(', ');
370
+ lines.push(` ${name} — ${status}`);
371
+ }
372
+ }
373
+ } else {
374
+ lines.push(`${user.email} (invited, ${user.status})`);
375
+ }
376
+ lines.push('');
377
+ }
378
+
379
+ return lines.join('\n').trim();
380
+ }
381
+
382
+ // ── Restore session on startup ────────────────────────────────────────
383
+
384
+ loadSession();
385
+
386
+ // ── MCP Server ────────────────────────────────────────────────────────
387
+
388
+ const server = new McpServer({
389
+ name: 'rivian',
390
+ version: '1.0.0',
391
+ });
392
+
393
+ // ── Auth tools ────────────────────────────────────────────────────────
394
+
395
+ server.tool(
396
+ 'rivian_login',
397
+ 'Log in to your Rivian account. Rivian will send a verification code to your phone or email — use rivian_submit_otp to complete sign-in.',
398
+ {},
399
+ async () => {
400
+ const email = process.env.RIVIAN_EMAIL;
401
+ const password = process.env.RIVIAN_PASSWORD;
402
+ if (!email || !password) {
403
+ return text(
404
+ 'Rivian credentials are not configured. Set RIVIAN_EMAIL and RIVIAN_PASSWORD in your MCP server settings.',
405
+ );
406
+ }
407
+
408
+ try {
409
+ await rivian.createCsrfToken();
410
+ const { mfa } = await rivian.login(email, password);
411
+ saveSession();
412
+
413
+ if (mfa) {
414
+ return text(
415
+ "A verification code has been sent to your phone or email. Tell me the code and I'll complete the sign-in.",
416
+ );
417
+ }
418
+
419
+ return text('Signed in to Rivian successfully.');
420
+ } catch (err) {
421
+ return text(`Couldn't sign in: ${err.message}`);
422
+ }
423
+ },
424
+ );
425
+
426
+ server.tool(
427
+ 'rivian_submit_otp',
428
+ 'Complete Rivian sign-in with the verification code sent to your phone or email.',
429
+ { otp_code: z.string().describe('The verification code') },
430
+ async ({ otp_code }) => {
431
+ const email = process.env.RIVIAN_EMAIL;
432
+ if (!email) {
433
+ return text('RIVIAN_EMAIL is not configured.');
434
+ }
435
+
436
+ if (!rivian.needsOtp()) {
437
+ return text('No pending verification. Start with rivian_login first.');
438
+ }
439
+
440
+ try {
441
+ await rivian.validateOtp(email, otp_code);
442
+ saveSession();
443
+ return text('Signed in to Rivian successfully.');
444
+ } catch (err) {
445
+ return text(
446
+ `Verification failed: ${err.message}. You may need to start over with rivian_login.`,
447
+ );
448
+ }
449
+ },
450
+ );
451
+
452
+ // ── Read-only query tools ─────────────────────────────────────────────
453
+
454
+ server.tool(
455
+ 'rivian_get_user_info',
456
+ 'Look up your Rivian account — your vehicles, software versions, and account details.',
457
+ {},
458
+ async () => {
459
+ try {
460
+ requireAuth();
461
+ return text(formatUserInfo(await rivian.getUserInfo()));
462
+ } catch (err) {
463
+ return text(err.message);
464
+ }
465
+ },
466
+ );
467
+
468
+ server.tool(
469
+ 'rivian_get_vehicle_state',
470
+ "Check your vehicle's current status — battery, range, doors, tires, location, climate, software, and more.",
471
+ {
472
+ vehicle_id: z.string().describe('Vehicle ID from your account info'),
473
+ properties: z
474
+ .array(z.string())
475
+ .optional()
476
+ .describe('Specific properties to check. Leave empty for a full status report.'),
477
+ },
478
+ async ({ vehicle_id, properties }) => {
479
+ try {
480
+ requireAuth();
481
+ const props = properties ? new Set(properties) : undefined;
482
+ return text(formatVehicleState(await rivian.getVehicleState(vehicle_id, props)));
483
+ } catch (err) {
484
+ return text(err.message);
485
+ }
486
+ },
487
+ );
488
+
489
+ server.tool(
490
+ 'rivian_get_ota_status',
491
+ "Check for software updates — what version you're running and whether a new one is available.",
492
+ {
493
+ vehicle_id: z.string().describe('Vehicle ID from your account info'),
494
+ },
495
+ async ({ vehicle_id }) => {
496
+ try {
497
+ requireAuth();
498
+ return text(formatOTAStatus(await rivian.getOTAUpdateDetails(vehicle_id)));
499
+ } catch (err) {
500
+ return text(err.message);
501
+ }
502
+ },
503
+ );
504
+
505
+ server.tool(
506
+ 'rivian_get_charging_session',
507
+ 'Check on an active charging session — power, battery level, time remaining, and cost.',
508
+ {
509
+ vehicle_id: z.string().describe('Vehicle ID from your account info'),
510
+ },
511
+ async ({ vehicle_id }) => {
512
+ try {
513
+ requireAuth();
514
+ return text(formatChargingSession(await rivian.getLiveChargingSession(vehicle_id)));
515
+ } catch (err) {
516
+ return text(err.message);
517
+ }
518
+ },
519
+ );
520
+
521
+ server.tool(
522
+ 'rivian_get_drivers_and_keys',
523
+ 'See who has access to your vehicle — drivers, phone keys, and key fobs.',
524
+ {
525
+ vehicle_id: z.string().describe('Vehicle ID from your account info'),
526
+ },
527
+ async ({ vehicle_id }) => {
528
+ try {
529
+ requireAuth();
530
+ return text(formatDriversAndKeys(await rivian.getDriversAndKeys(vehicle_id)));
531
+ } catch (err) {
532
+ return text(err.message);
533
+ }
534
+ },
535
+ );
536
+
537
+ // ── Start ─────────────────────────────────────────────────────────────
538
+
539
+ const transport = new StdioServerTransport();
540
+ await server.connect(transport);
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "rivian-mcp",
3
+ "version": "1.0.0",
4
+ "description": "Read-only MCP server for the Rivian API — vehicle state, OTA status, charging, and more",
5
+ "type": "module",
6
+ "bin": {
7
+ "rivian-mcp": "mcp-server.js"
8
+ },
9
+ "main": "mcp-server.js",
10
+ "files": [
11
+ "mcp-server.js",
12
+ "lib/"
13
+ ],
14
+ "scripts": {
15
+ "start": "node mcp-server.js"
16
+ },
17
+ "keywords": [
18
+ "rivian",
19
+ "mcp",
20
+ "ev",
21
+ "vehicle",
22
+ "api"
23
+ ],
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/PatrickHeneise/rivian-mcp.git"
27
+ },
28
+ "homepage": "https://github.com/PatrickHeneise/rivian-mcp#readme",
29
+ "bugs": {
30
+ "url": "https://github.com/PatrickHeneise/rivian-mcp/issues"
31
+ },
32
+ "author": "Patrick Heneise",
33
+ "license": "MIT",
34
+ "publishConfig": {
35
+ "registry": "https://registry.npmjs.org/",
36
+ "tag": "latest",
37
+ "access": "public"
38
+ },
39
+ "engines": {
40
+ "node": ">=24"
41
+ },
42
+ "dependencies": {
43
+ "@modelcontextprotocol/sdk": "^1.27.1",
44
+ "zod": "^3.24.0"
45
+ },
46
+ "devDependencies": {
47
+ "semantic-release": "^25.0.3"
48
+ }
49
+ }