@letterapp/mcp 0.1.0 → 0.2.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/README.md +95 -23
- package/dist/index.js +183 -127
- package/dist/index.js.map +1 -0
- package/package.json +6 -5
- package/dist/credentials.js +0 -41
package/README.md
CHANGED
|
@@ -1,52 +1,124 @@
|
|
|
1
1
|
# @letterapp/mcp
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@letterapp/mcp)
|
|
4
|
+
[](./LICENSE)
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
**Letter MCP server** — a [Model Context Protocol](https://modelcontextprotocol.io) server that gives your AI coding agent tools to set up and verify a [Letter](https://letter.app) integration.
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
First authenticate once from your project:
|
|
8
|
+
Built for AI coding agents (Claude Code, Cursor, Codex, Windsurf, Cline, OpenClaw). It reads the credential written by [`@letterapp/cli`](https://www.npmjs.com/package/@letterapp/cli) (`~/.letter/credentials.json`), so **your MCP config never contains a secret** and the key never appears in chat.
|
|
10
9
|
|
|
11
|
-
|
|
12
|
-
npx @letterapp/cli
|
|
13
|
-
```
|
|
10
|
+
---
|
|
14
11
|
|
|
15
|
-
|
|
12
|
+
## Installation
|
|
16
13
|
|
|
17
|
-
|
|
14
|
+
No install needed - your MCP client launches it on demand with `npx`. Add it to your client config:
|
|
18
15
|
|
|
19
16
|
```json
|
|
20
17
|
{
|
|
21
18
|
"mcpServers": {
|
|
22
|
-
"letter": {
|
|
23
|
-
"command": "npx",
|
|
24
|
-
"args": ["-y", "@letterapp/mcp"]
|
|
25
|
-
}
|
|
19
|
+
"letter": { "command": "npx", "args": ["-y", "@letterapp/mcp"] }
|
|
26
20
|
}
|
|
27
21
|
}
|
|
28
22
|
```
|
|
29
23
|
|
|
30
|
-
|
|
24
|
+
Or install globally:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npm install -g @letterapp/mcp
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Setup
|
|
33
|
+
|
|
34
|
+
First authenticate once from your project (this is what writes the credential the server reads):
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
npx @letterapp/cli
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
What happens:
|
|
41
|
+
|
|
42
|
+
1. The CLI opens a browser to confirm a short code and pick the project to connect.
|
|
43
|
+
2. Letter mints a project API key and delivers it to the CLI over a secure back channel. The CLI writes `LETTER_API_KEY` to `.env.local` and stores a copy in `~/.letter/credentials.json`.
|
|
44
|
+
3. This server resolves that credential automatically - no API key in your MCP config.
|
|
45
|
+
|
|
46
|
+
The API key is never printed to the terminal or chat. In CI you can instead set `LETTER_API_KEY` in the environment.
|
|
47
|
+
|
|
48
|
+
---
|
|
31
49
|
|
|
32
50
|
## Tools
|
|
33
51
|
|
|
34
52
|
| Tool | Description |
|
|
35
53
|
| --- | --- |
|
|
36
|
-
| `setup_guide` | Returns Letter's step-by-step integration guide (markdown). |
|
|
37
|
-
| `check_connection` | Reports whether the connected project has received any contacts or events. |
|
|
38
|
-
| `send_test_event` | Sends a test `identify` + `track` to prove the pipe works. |
|
|
39
|
-
| `identify` | Report a user (`userId`, optional `email`, `traits`). |
|
|
40
|
-
| `track` | Report an event (`userId`, `event`, optional `properties`). |
|
|
54
|
+
| `setup_guide` | Returns Letter's current step-by-step integration guide (markdown an agent can follow). |
|
|
55
|
+
| `check_connection` | Reports whether the connected project has received any contacts or events yet. |
|
|
56
|
+
| `send_test_event` | Sends a test `identify` + `track` to prove the pipe works end to end. Optional `userId` / `email`. |
|
|
57
|
+
| `identify` | Report a user to Letter (`userId`, optional `email`, `traits`). |
|
|
58
|
+
| `track` | Report an event a user performed (`userId`, `event`, optional `properties`). |
|
|
59
|
+
|
|
60
|
+
When no credential is found, every tool returns a friendly "run `npx @letterapp/cli` first" message instead of failing.
|
|
61
|
+
|
|
62
|
+
---
|
|
41
63
|
|
|
42
|
-
## Environment
|
|
64
|
+
## Environment Variables
|
|
43
65
|
|
|
44
66
|
| Variable | Default | Purpose |
|
|
45
67
|
| --- | --- | --- |
|
|
46
|
-
| `LETTER_API_KEY` | (from credential file) | Overrides the stored credential. |
|
|
47
|
-
| `LETTER_BASE_URL` | `https://api.letter.app` | API origin
|
|
68
|
+
| `LETTER_API_KEY` | (from credential file) | Overrides the stored credential (CI). |
|
|
69
|
+
| `LETTER_BASE_URL` | `https://api.letter.app` | API origin for self-host / local dev. |
|
|
48
70
|
| `LETTER_DOCS_URL` | `https://letter.app` | Docs origin used by `setup_guide`. |
|
|
49
71
|
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## Security
|
|
75
|
+
|
|
76
|
+
The server never receives or stores a raw key in its config. It resolves the credential from `~/.letter/credentials.json` (written by the CLI's browser-approved device flow) or from `LETTER_API_KEY` in the environment. Secrets are never logged or echoed back through tool results. Treat `.env.local` and `~/.letter/credentials.json` as secrets and keep them out of source control.
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## Project Structure
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
src/
|
|
84
|
+
├── index.ts # MCP server entry — registers tools over stdio
|
|
85
|
+
└── credentials.ts # Resolves the Letter credential (env or ~/.letter)
|
|
86
|
+
package.json
|
|
87
|
+
tsconfig.json
|
|
88
|
+
tsup.config.ts
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## Development
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
npm install # install dependencies
|
|
97
|
+
npm run build # bundle to dist/ with tsup
|
|
98
|
+
npm run dev # watch mode
|
|
99
|
+
npm run typecheck
|
|
100
|
+
|
|
101
|
+
node dist/index.js # run the server over stdio
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## See Also
|
|
107
|
+
|
|
108
|
+
- [`@letterapp/cli`](https://www.npmjs.com/package/@letterapp/cli) — connect your app to Letter in one command.
|
|
109
|
+
- [`@letterapp/node`](https://www.npmjs.com/package/@letterapp/node) — Node.js SDK.
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## Links
|
|
114
|
+
|
|
115
|
+
- **Website:** [letter.app](https://letter.app)
|
|
116
|
+
- **npm:** [@letterapp/mcp](https://www.npmjs.com/package/@letterapp/mcp)
|
|
117
|
+
- **GitHub:** [vincenzor/letter-mcp](https://github.com/vincenzor/letter-mcp)
|
|
118
|
+
- **Issues:** [Report a bug](https://github.com/vincenzor/letter-mcp/issues)
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
50
122
|
## License
|
|
51
123
|
|
|
52
124
|
MIT
|
package/dist/index.js
CHANGED
|
@@ -1,140 +1,196 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
2
4
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
5
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
6
|
import { z } from "zod";
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
|
|
8
|
+
// src/credentials.ts
|
|
9
|
+
import { homedir } from "os";
|
|
10
|
+
import path from "path";
|
|
11
|
+
import { readFile } from "fs/promises";
|
|
12
|
+
var DEFAULT_API_BASE = "https://api.letter.app";
|
|
13
|
+
var DEFAULT_DOCS_BASE = "https://letter.app";
|
|
14
|
+
async function resolveCredential() {
|
|
15
|
+
const envKey = process.env.LETTER_API_KEY;
|
|
16
|
+
if (envKey) {
|
|
17
|
+
return {
|
|
18
|
+
apiKey: envKey,
|
|
19
|
+
baseUrl: (process.env.LETTER_BASE_URL || DEFAULT_API_BASE).replace(
|
|
20
|
+
/\/$/,
|
|
21
|
+
""
|
|
22
|
+
)
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
const file = path.join(homedir(), ".letter", "credentials.json");
|
|
27
|
+
const parsed = JSON.parse(await readFile(file, "utf8"));
|
|
28
|
+
if (!parsed.apiKey) return null;
|
|
29
|
+
return {
|
|
30
|
+
apiKey: parsed.apiKey,
|
|
31
|
+
baseUrl: (parsed.baseUrl || DEFAULT_API_BASE).replace(/\/$/, ""),
|
|
32
|
+
project: parsed.project
|
|
33
|
+
};
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function docsBase() {
|
|
39
|
+
return (process.env.LETTER_DOCS_URL || DEFAULT_DOCS_BASE).replace(/\/$/, "");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// src/index.ts
|
|
43
|
+
var VERSION = "0.2.0";
|
|
44
|
+
var USER_AGENT = "@letterapp/mcp";
|
|
8
45
|
function text(t, isError = false) {
|
|
9
|
-
|
|
46
|
+
return { content: [{ type: "text", text: t }], isError };
|
|
10
47
|
}
|
|
11
|
-
|
|
48
|
+
var NOT_CONNECTED = text(
|
|
49
|
+
"No Letter credential found. Run `npx @letterapp/cli` in your project to log in (it writes ~/.letter/credentials.json), or set LETTER_API_KEY in the environment. The key is provisioned via a browser confirmation - never paste it into chat.",
|
|
50
|
+
true
|
|
51
|
+
);
|
|
12
52
|
async function api(cred, method, pathname, body) {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
return { ok: res.ok, status: res.status, json };
|
|
53
|
+
const res = await fetch(`${cred.baseUrl}${pathname}`, {
|
|
54
|
+
method,
|
|
55
|
+
headers: {
|
|
56
|
+
authorization: `Bearer ${cred.apiKey}`,
|
|
57
|
+
"content-type": "application/json",
|
|
58
|
+
"user-agent": USER_AGENT
|
|
59
|
+
},
|
|
60
|
+
body: body === void 0 ? void 0 : JSON.stringify(body)
|
|
61
|
+
});
|
|
62
|
+
let json = null;
|
|
63
|
+
try {
|
|
64
|
+
json = await res.json();
|
|
65
|
+
} catch {
|
|
66
|
+
json = null;
|
|
67
|
+
}
|
|
68
|
+
return { ok: res.ok, status: res.status, json };
|
|
30
69
|
}
|
|
31
70
|
async function main() {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
42
|
-
catch {
|
|
43
|
-
// fall through to the built-in summary
|
|
44
|
-
}
|
|
45
|
-
return text([
|
|
46
|
-
"# Letter setup",
|
|
47
|
-
"1. Run `npx @letterapp/cli` in the project root to authenticate (browser confirm) and install @letterapp/node. It writes LETTER_API_KEY to .env.local.",
|
|
48
|
-
"2. Create a server-side client: `new Letter({ apiKey: process.env.LETTER_API_KEY! })`.",
|
|
49
|
-
"3. Call `letter.identify({ userId, email, traits })` where users sign up/log in.",
|
|
50
|
-
"4. Call `letter.track({ userId, event })` on 2-3 key actions.",
|
|
51
|
-
"5. Verify with the `check_connection` tool.",
|
|
52
|
-
"Full docs: https://letter.app/docs/agent-setup",
|
|
53
|
-
].join("\n"));
|
|
54
|
-
});
|
|
55
|
-
// check_connection: confirm the project has received data.
|
|
56
|
-
server.tool("check_connection", "Check whether the connected Letter project has received any contacts or events yet. Use to verify the integration is live.", {}, async () => {
|
|
57
|
-
const cred = await resolveCredential();
|
|
58
|
-
if (!cred)
|
|
59
|
-
return NOT_CONNECTED;
|
|
60
|
-
const { ok, status, json } = await api(cred, "GET", "/v1/status");
|
|
61
|
-
if (!ok) {
|
|
62
|
-
return text(`Could not reach Letter (HTTP ${status}).`, true);
|
|
63
|
-
}
|
|
64
|
-
const data = json;
|
|
65
|
-
const name = data.project?.name ?? cred.project?.name ?? "your project";
|
|
66
|
-
return text(data.connected
|
|
67
|
-
? `Connected. ${name} has ${data.contacts ?? 0} contact(s) and ${data.events ?? 0} event(s).`
|
|
68
|
-
: `${name} is set up but hasn't received any data yet. Trigger an identify/track call (or use send_test_event).`);
|
|
69
|
-
});
|
|
70
|
-
// send_test_event: prove the pipe end to end.
|
|
71
|
-
server.tool("send_test_event", "Send a test identify + track to Letter to prove the connection works. Optionally pass a userId/email.", {
|
|
72
|
-
userId: z.string().optional(),
|
|
73
|
-
email: z.string().optional(),
|
|
74
|
-
}, async ({ userId, email }) => {
|
|
75
|
-
const cred = await resolveCredential();
|
|
76
|
-
if (!cred)
|
|
77
|
-
return NOT_CONNECTED;
|
|
78
|
-
const uid = userId || `mcp_test_${Date.now()}`;
|
|
79
|
-
const mail = email || `${uid}@example.com`;
|
|
80
|
-
const idRes = await api(cred, "POST", "/v1/identify", {
|
|
81
|
-
userId: uid,
|
|
82
|
-
email: mail,
|
|
83
|
-
traits: { name: "MCP Test", source: "mcp" },
|
|
84
|
-
});
|
|
85
|
-
if (!idRes.ok) {
|
|
86
|
-
return text(`identify failed (HTTP ${idRes.status}).`, true);
|
|
87
|
-
}
|
|
88
|
-
const trackRes = await api(cred, "POST", "/v1/track", {
|
|
89
|
-
userId: uid,
|
|
90
|
-
event: "MCP Test Event",
|
|
91
|
-
properties: { via: "@letterapp/mcp" },
|
|
92
|
-
});
|
|
93
|
-
if (!trackRes.ok) {
|
|
94
|
-
return text(`track failed (HTTP ${trackRes.status}).`, true);
|
|
95
|
-
}
|
|
96
|
-
return text(`Sent a test contact (${uid}) and a "MCP Test Event". Check your Letter dashboard, or run check_connection.`);
|
|
97
|
-
});
|
|
98
|
-
// identify passthrough.
|
|
99
|
-
server.tool("identify", "Report a user to Letter (Segment-style identify). Use a stable userId.", {
|
|
100
|
-
userId: z.string(),
|
|
101
|
-
email: z.string().optional(),
|
|
102
|
-
traits: z.record(z.any()).optional(),
|
|
103
|
-
}, async ({ userId, email, traits }) => {
|
|
104
|
-
const cred = await resolveCredential();
|
|
105
|
-
if (!cred)
|
|
106
|
-
return NOT_CONNECTED;
|
|
107
|
-
const res = await api(cred, "POST", "/v1/identify", {
|
|
108
|
-
userId,
|
|
109
|
-
email,
|
|
110
|
-
traits,
|
|
71
|
+
const server = new McpServer({ name: "letter", version: VERSION });
|
|
72
|
+
server.tool(
|
|
73
|
+
"setup_guide",
|
|
74
|
+
"Get Letter's step-by-step integration guide (install, authenticate, instrument identify/track). Returns markdown an agent can follow.",
|
|
75
|
+
{},
|
|
76
|
+
async () => {
|
|
77
|
+
try {
|
|
78
|
+
const res = await fetch(`${docsBase()}/docs/agent-setup/raw`, {
|
|
79
|
+
headers: { "user-agent": USER_AGENT }
|
|
111
80
|
});
|
|
112
|
-
return res.
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
81
|
+
if (res.ok) return text(await res.text());
|
|
82
|
+
} catch {
|
|
83
|
+
}
|
|
84
|
+
return text(
|
|
85
|
+
[
|
|
86
|
+
"# Letter setup",
|
|
87
|
+
"1. Run `npx @letterapp/cli` in the project root to authenticate (browser confirm) and install @letterapp/node. It writes LETTER_API_KEY to .env.local.",
|
|
88
|
+
"2. Create a server-side client: `new Letter({ apiKey: process.env.LETTER_API_KEY! })`.",
|
|
89
|
+
"3. Call `letter.identify({ userId, email, traits })` where users sign up/log in.",
|
|
90
|
+
"4. Call `letter.track({ userId, event })` on 2-3 key actions.",
|
|
91
|
+
"5. Verify with the `check_connection` tool.",
|
|
92
|
+
"Full docs: https://letter.app/docs/agent-setup"
|
|
93
|
+
].join("\n")
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
);
|
|
97
|
+
server.tool(
|
|
98
|
+
"check_connection",
|
|
99
|
+
"Check whether the connected Letter project has received any contacts or events yet. Use to verify the integration is live.",
|
|
100
|
+
{},
|
|
101
|
+
async () => {
|
|
102
|
+
const cred = await resolveCredential();
|
|
103
|
+
if (!cred) return NOT_CONNECTED;
|
|
104
|
+
const { ok, status, json } = await api(cred, "GET", "/v1/status");
|
|
105
|
+
if (!ok) {
|
|
106
|
+
return text(`Could not reach Letter (HTTP ${status}).`, true);
|
|
107
|
+
}
|
|
108
|
+
const data = json;
|
|
109
|
+
const name = data.project?.name ?? cred.project?.name ?? "your project";
|
|
110
|
+
return text(
|
|
111
|
+
data.connected ? `Connected. ${name} has ${data.contacts ?? 0} contact(s) and ${data.events ?? 0} event(s).` : `${name} is set up but hasn't received any data yet. Trigger an identify/track call (or use send_test_event).`
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
);
|
|
115
|
+
server.tool(
|
|
116
|
+
"send_test_event",
|
|
117
|
+
"Send a test identify + track to Letter to prove the connection works. Optionally pass a userId/email.",
|
|
118
|
+
{
|
|
119
|
+
userId: z.string().optional(),
|
|
120
|
+
email: z.string().optional()
|
|
121
|
+
},
|
|
122
|
+
async ({ userId, email }) => {
|
|
123
|
+
const cred = await resolveCredential();
|
|
124
|
+
if (!cred) return NOT_CONNECTED;
|
|
125
|
+
const uid = userId || `mcp_test_${Date.now()}`;
|
|
126
|
+
const mail = email || `${uid}@example.com`;
|
|
127
|
+
const idRes = await api(cred, "POST", "/v1/identify", {
|
|
128
|
+
userId: uid,
|
|
129
|
+
email: mail,
|
|
130
|
+
traits: { name: "MCP Test", source: "mcp" }
|
|
131
|
+
});
|
|
132
|
+
if (!idRes.ok) {
|
|
133
|
+
return text(`identify failed (HTTP ${idRes.status}).`, true);
|
|
134
|
+
}
|
|
135
|
+
const trackRes = await api(cred, "POST", "/v1/track", {
|
|
136
|
+
userId: uid,
|
|
137
|
+
event: "MCP Test Event",
|
|
138
|
+
properties: { via: "@letterapp/mcp" }
|
|
139
|
+
});
|
|
140
|
+
if (!trackRes.ok) {
|
|
141
|
+
return text(`track failed (HTTP ${trackRes.status}).`, true);
|
|
142
|
+
}
|
|
143
|
+
return text(
|
|
144
|
+
`Sent a test contact (${uid}) and a "MCP Test Event". Check your Letter dashboard, or run check_connection.`
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
);
|
|
148
|
+
server.tool(
|
|
149
|
+
"identify",
|
|
150
|
+
"Report a user to Letter (Segment-style identify). Use a stable userId.",
|
|
151
|
+
{
|
|
152
|
+
userId: z.string(),
|
|
153
|
+
email: z.string().optional(),
|
|
154
|
+
traits: z.record(z.any()).optional()
|
|
155
|
+
},
|
|
156
|
+
async ({ userId, email, traits }) => {
|
|
157
|
+
const cred = await resolveCredential();
|
|
158
|
+
if (!cred) return NOT_CONNECTED;
|
|
159
|
+
const res = await api(cred, "POST", "/v1/identify", {
|
|
160
|
+
userId,
|
|
161
|
+
email,
|
|
162
|
+
traits
|
|
163
|
+
});
|
|
164
|
+
return res.ok ? text(`Identified ${userId}.`) : text(`identify failed (HTTP ${res.status}).`, true);
|
|
165
|
+
}
|
|
166
|
+
);
|
|
167
|
+
server.tool(
|
|
168
|
+
"track",
|
|
169
|
+
"Report an event a user performed to Letter (Segment-style track).",
|
|
170
|
+
{
|
|
171
|
+
userId: z.string(),
|
|
172
|
+
event: z.string(),
|
|
173
|
+
properties: z.record(z.any()).optional()
|
|
174
|
+
},
|
|
175
|
+
async ({ userId, event, properties }) => {
|
|
176
|
+
const cred = await resolveCredential();
|
|
177
|
+
if (!cred) return NOT_CONNECTED;
|
|
178
|
+
const res = await api(cred, "POST", "/v1/track", {
|
|
179
|
+
userId,
|
|
180
|
+
event,
|
|
181
|
+
properties
|
|
182
|
+
});
|
|
183
|
+
return res.ok ? text(`Tracked "${event}" for ${userId}.`) : text(`track failed (HTTP ${res.status}).`, true);
|
|
184
|
+
}
|
|
185
|
+
);
|
|
186
|
+
const transport = new StdioServerTransport();
|
|
187
|
+
await server.connect(transport);
|
|
136
188
|
}
|
|
137
189
|
main().catch((err) => {
|
|
138
|
-
|
|
139
|
-
|
|
190
|
+
process.stderr.write(
|
|
191
|
+
`letter-mcp failed to start: ${err instanceof Error ? err.message : String(err)}
|
|
192
|
+
`
|
|
193
|
+
);
|
|
194
|
+
process.exit(1);
|
|
140
195
|
});
|
|
196
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/credentials.ts"],"sourcesContent":["declare const PKG_VERSION: string;\nimport { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\";\nimport { StdioServerTransport } from \"@modelcontextprotocol/sdk/server/stdio.js\";\nimport { z } from \"zod\";\nimport {\n type Credential,\n docsBase,\n resolveCredential,\n} from \"./credentials.js\";\n\nconst VERSION = PKG_VERSION;\nconst USER_AGENT = \"@letterapp/mcp\";\n\ntype TextResult = {\n content: { type: \"text\"; text: string }[];\n isError?: boolean;\n};\n\nfunction text(t: string, isError = false): TextResult {\n return { content: [{ type: \"text\", text: t }], isError };\n}\n\nconst NOT_CONNECTED = text(\n \"No Letter credential found. Run `npx @letterapp/cli` in your project to log in (it writes ~/.letter/credentials.json), or set LETTER_API_KEY in the environment. The key is provisioned via a browser confirmation - never paste it into chat.\",\n true,\n);\n\nasync function api(\n cred: Credential,\n method: \"GET\" | \"POST\",\n pathname: string,\n body?: unknown,\n): Promise<{ ok: boolean; status: number; json: unknown }> {\n const res = await fetch(`${cred.baseUrl}${pathname}`, {\n method,\n headers: {\n authorization: `Bearer ${cred.apiKey}`,\n \"content-type\": \"application/json\",\n \"user-agent\": USER_AGENT,\n },\n body: body === undefined ? undefined : JSON.stringify(body),\n });\n let json: unknown = null;\n try {\n json = await res.json();\n } catch {\n json = null;\n }\n return { ok: res.ok, status: res.status, json };\n}\n\nasync function main(): Promise<void> {\n const server = new McpServer({ name: \"letter\", version: VERSION });\n\n // setup_guide: hand the agent the full, current integration steps.\n server.tool(\n \"setup_guide\",\n \"Get Letter's step-by-step integration guide (install, authenticate, instrument identify/track). Returns markdown an agent can follow.\",\n {},\n async () => {\n try {\n const res = await fetch(`${docsBase()}/docs/agent-setup/raw`, {\n headers: { \"user-agent\": USER_AGENT },\n });\n if (res.ok) return text(await res.text());\n } catch {\n // fall through to the built-in summary\n }\n return text(\n [\n \"# Letter setup\",\n \"1. Run `npx @letterapp/cli` in the project root to authenticate (browser confirm) and install @letterapp/node. It writes LETTER_API_KEY to .env.local.\",\n \"2. Create a server-side client: `new Letter({ apiKey: process.env.LETTER_API_KEY! })`.\",\n \"3. Call `letter.identify({ userId, email, traits })` where users sign up/log in.\",\n \"4. Call `letter.track({ userId, event })` on 2-3 key actions.\",\n \"5. Verify with the `check_connection` tool.\",\n \"Full docs: https://letter.app/docs/agent-setup\",\n ].join(\"\\n\"),\n );\n },\n );\n\n // check_connection: confirm the project has received data.\n server.tool(\n \"check_connection\",\n \"Check whether the connected Letter project has received any contacts or events yet. Use to verify the integration is live.\",\n {},\n async () => {\n const cred = await resolveCredential();\n if (!cred) return NOT_CONNECTED;\n const { ok, status, json } = await api(cred, \"GET\", \"/v1/status\");\n if (!ok) {\n return text(`Could not reach Letter (HTTP ${status}).`, true);\n }\n const data = json as {\n contacts?: number;\n events?: number;\n connected?: boolean;\n project?: { name?: string };\n };\n const name = data.project?.name ?? cred.project?.name ?? \"your project\";\n return text(\n data.connected\n ? `Connected. ${name} has ${data.contacts ?? 0} contact(s) and ${data.events ?? 0} event(s).`\n : `${name} is set up but hasn't received any data yet. Trigger an identify/track call (or use send_test_event).`,\n );\n },\n );\n\n // send_test_event: prove the pipe end to end.\n server.tool(\n \"send_test_event\",\n \"Send a test identify + track to Letter to prove the connection works. Optionally pass a userId/email.\",\n {\n userId: z.string().optional(),\n email: z.string().optional(),\n },\n async ({ userId, email }) => {\n const cred = await resolveCredential();\n if (!cred) return NOT_CONNECTED;\n const uid = userId || `mcp_test_${Date.now()}`;\n const mail = email || `${uid}@example.com`;\n\n const idRes = await api(cred, \"POST\", \"/v1/identify\", {\n userId: uid,\n email: mail,\n traits: { name: \"MCP Test\", source: \"mcp\" },\n });\n if (!idRes.ok) {\n return text(`identify failed (HTTP ${idRes.status}).`, true);\n }\n const trackRes = await api(cred, \"POST\", \"/v1/track\", {\n userId: uid,\n event: \"MCP Test Event\",\n properties: { via: \"@letterapp/mcp\" },\n });\n if (!trackRes.ok) {\n return text(`track failed (HTTP ${trackRes.status}).`, true);\n }\n return text(\n `Sent a test contact (${uid}) and a \"MCP Test Event\". Check your Letter dashboard, or run check_connection.`,\n );\n },\n );\n\n // identify passthrough.\n server.tool(\n \"identify\",\n \"Report a user to Letter (Segment-style identify). Use a stable userId.\",\n {\n userId: z.string(),\n email: z.string().optional(),\n traits: z.record(z.any()).optional(),\n },\n async ({ userId, email, traits }) => {\n const cred = await resolveCredential();\n if (!cred) return NOT_CONNECTED;\n const res = await api(cred, \"POST\", \"/v1/identify\", {\n userId,\n email,\n traits,\n });\n return res.ok\n ? text(`Identified ${userId}.`)\n : text(`identify failed (HTTP ${res.status}).`, true);\n },\n );\n\n // track passthrough.\n server.tool(\n \"track\",\n \"Report an event a user performed to Letter (Segment-style track).\",\n {\n userId: z.string(),\n event: z.string(),\n properties: z.record(z.any()).optional(),\n },\n async ({ userId, event, properties }) => {\n const cred = await resolveCredential();\n if (!cred) return NOT_CONNECTED;\n const res = await api(cred, \"POST\", \"/v1/track\", {\n userId,\n event,\n properties,\n });\n return res.ok\n ? text(`Tracked \"${event}\" for ${userId}.`)\n : text(`track failed (HTTP ${res.status}).`, true);\n },\n );\n\n const transport = new StdioServerTransport();\n await server.connect(transport);\n}\n\nmain().catch((err) => {\n process.stderr.write(\n `letter-mcp failed to start: ${err instanceof Error ? err.message : String(err)}\\n`,\n );\n process.exit(1);\n});\n","import { homedir } from \"node:os\";\nimport path from \"node:path\";\nimport { readFile } from \"node:fs/promises\";\n\nexport const DEFAULT_API_BASE = \"https://api.letter.app\";\nexport const DEFAULT_DOCS_BASE = \"https://letter.app\";\n\nexport type Credential = {\n apiKey: string;\n baseUrl: string;\n project?: { slug: string; name: string };\n};\n\ntype StoredCredential = {\n apiKey: string;\n baseUrl?: string;\n project?: { slug: string; name: string };\n};\n\n/**\n * Resolves the Letter credential for the MCP server, in priority order:\n * 1. LETTER_API_KEY (+ LETTER_BASE_URL) from the environment.\n * 2. ~/.letter/credentials.json written by `npx @letterapp/cli`.\n *\n * Returns null when neither is present, so tools can return a friendly\n * \"run `npx @letterapp/cli` first\" message instead of crashing. The secret is\n * never logged or echoed back through tool results.\n */\nexport async function resolveCredential(): Promise<Credential | null> {\n const envKey = process.env.LETTER_API_KEY;\n if (envKey) {\n return {\n apiKey: envKey,\n baseUrl: (process.env.LETTER_BASE_URL || DEFAULT_API_BASE).replace(\n /\\/$/,\n \"\",\n ),\n };\n }\n\n try {\n const file = path.join(homedir(), \".letter\", \"credentials.json\");\n const parsed = JSON.parse(await readFile(file, \"utf8\")) as StoredCredential;\n if (!parsed.apiKey) return null;\n return {\n apiKey: parsed.apiKey,\n baseUrl: (parsed.baseUrl || DEFAULT_API_BASE).replace(/\\/$/, \"\"),\n project: parsed.project,\n };\n } catch {\n return null;\n }\n}\n\n/** Docs origin for fetching the agent-setup guide. */\nexport function docsBase(): string {\n return (process.env.LETTER_DOCS_URL || DEFAULT_DOCS_BASE).replace(/\\/$/, \"\");\n}\n"],"mappings":"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","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@letterapp/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Letter MCP server - give your AI agent tools to set up and verify a Letter integration. Reads the credential written by `letter login`; no secret in your MCP config.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://letter.app",
|
|
@@ -36,10 +36,10 @@
|
|
|
36
36
|
"access": "public"
|
|
37
37
|
},
|
|
38
38
|
"scripts": {
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
"
|
|
42
|
-
"
|
|
39
|
+
"dev": "tsup --watch",
|
|
40
|
+
"build": "tsup",
|
|
41
|
+
"start": "node dist/index.js",
|
|
42
|
+
"typecheck": "tsc --noEmit"
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
45
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|
|
49
49
|
"@types/node": "^22.10.2",
|
|
50
|
+
"tsup": "^8.4.0",
|
|
50
51
|
"typescript": "^5.7.2"
|
|
51
52
|
}
|
|
52
53
|
}
|
package/dist/credentials.js
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
import { homedir } from "node:os";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import { readFile } from "node:fs/promises";
|
|
4
|
-
export const DEFAULT_API_BASE = "https://api.letter.app";
|
|
5
|
-
export const DEFAULT_DOCS_BASE = "https://letter.app";
|
|
6
|
-
/**
|
|
7
|
-
* Resolves the Letter credential for the MCP server, in priority order:
|
|
8
|
-
* 1. LETTER_API_KEY (+ LETTER_BASE_URL) from the environment.
|
|
9
|
-
* 2. ~/.letter/credentials.json written by `npx @letterapp/cli`.
|
|
10
|
-
*
|
|
11
|
-
* Returns null when neither is present, so tools can return a friendly
|
|
12
|
-
* "run `npx @letterapp/cli` first" message instead of crashing. The secret is
|
|
13
|
-
* never logged or echoed back through tool results.
|
|
14
|
-
*/
|
|
15
|
-
export async function resolveCredential() {
|
|
16
|
-
const envKey = process.env.LETTER_API_KEY;
|
|
17
|
-
if (envKey) {
|
|
18
|
-
return {
|
|
19
|
-
apiKey: envKey,
|
|
20
|
-
baseUrl: (process.env.LETTER_BASE_URL || DEFAULT_API_BASE).replace(/\/$/, ""),
|
|
21
|
-
};
|
|
22
|
-
}
|
|
23
|
-
try {
|
|
24
|
-
const file = path.join(homedir(), ".letter", "credentials.json");
|
|
25
|
-
const parsed = JSON.parse(await readFile(file, "utf8"));
|
|
26
|
-
if (!parsed.apiKey)
|
|
27
|
-
return null;
|
|
28
|
-
return {
|
|
29
|
-
apiKey: parsed.apiKey,
|
|
30
|
-
baseUrl: (parsed.baseUrl || DEFAULT_API_BASE).replace(/\/$/, ""),
|
|
31
|
-
project: parsed.project,
|
|
32
|
-
};
|
|
33
|
-
}
|
|
34
|
-
catch {
|
|
35
|
-
return null;
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
/** Docs origin for fetching the agent-setup guide. */
|
|
39
|
-
export function docsBase() {
|
|
40
|
-
return (process.env.LETTER_DOCS_URL || DEFAULT_DOCS_BASE).replace(/\/$/, "");
|
|
41
|
-
}
|