@side-quest/x-api 0.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/CHANGELOG.md +10 -0
- package/LICENSE +21 -0
- package/README.md +382 -0
- package/dist/mcp/index.js +121 -0
- package/dist/shared/chunk-219wrwgz.js +357 -0
- package/dist/src/index.d.ts +151 -0
- package/dist/src/index.js +27 -0
- package/package.json +119 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
Initial release.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Nathan Vale
|
|
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,382 @@
|
|
|
1
|
+
# bun-typescript-starter
|
|
2
|
+
|
|
3
|
+
Modern TypeScript starter template with enterprise-grade tooling.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Bun** - Fast all-in-one JavaScript runtime and toolkit
|
|
8
|
+
- **TypeScript 5.9+** - Strict mode, ESM only
|
|
9
|
+
- **Biome** - Lightning-fast linting and formatting (replaces ESLint + Prettier)
|
|
10
|
+
- **Vitest** - Fast unit testing with native Bun support
|
|
11
|
+
- **Changesets** - Automated versioning and changelog generation
|
|
12
|
+
- **GitHub Actions** - Comprehensive CI/CD with OIDC npm publishing
|
|
13
|
+
- **Conventional Commits** - Enforced via commitlint + Husky
|
|
14
|
+
|
|
15
|
+
## Quick Start
|
|
16
|
+
|
|
17
|
+
### Prerequisites
|
|
18
|
+
|
|
19
|
+
- [Bun](https://bun.sh) installed (`curl -fsSL https://bun.sh/install | bash`)
|
|
20
|
+
- [GitHub CLI](https://cli.github.com) installed and authenticated (`gh auth login`)
|
|
21
|
+
- [npm account](https://www.npmjs.com) with a granular access token (see [NPM Token Setup](#npm-token-setup))
|
|
22
|
+
|
|
23
|
+
### Option A: GitHub CLI (Recommended)
|
|
24
|
+
|
|
25
|
+
Create a new repo from this template and set it up in one command:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
# Create repo from template
|
|
29
|
+
gh repo create myusername/my-lib --template nathanvale/bun-typescript-starter --public --clone
|
|
30
|
+
|
|
31
|
+
# Run setup (interactive)
|
|
32
|
+
cd my-lib
|
|
33
|
+
bun run setup
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Option B: CLI Mode (Non-Interactive)
|
|
37
|
+
|
|
38
|
+
For automated/scripted setups, pass all arguments via CLI flags:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
# Create repo from template
|
|
42
|
+
gh repo create myusername/my-lib --template nathanvale/bun-typescript-starter --public --clone
|
|
43
|
+
cd my-lib
|
|
44
|
+
|
|
45
|
+
# Run setup with all arguments (no prompts)
|
|
46
|
+
bun run setup -- \
|
|
47
|
+
--name "@myusername/my-lib" \
|
|
48
|
+
--description "My awesome library" \
|
|
49
|
+
--author "Your Name" \
|
|
50
|
+
--yes
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Option C: degit
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
npx degit nathanvale/bun-typescript-starter my-lib
|
|
57
|
+
cd my-lib
|
|
58
|
+
bun run setup
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Setup Script
|
|
62
|
+
|
|
63
|
+
The setup script configures your project and optionally creates the GitHub repository with all settings pre-configured.
|
|
64
|
+
|
|
65
|
+
### Interactive Mode
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
bun run setup
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Prompts for:
|
|
72
|
+
- Package name (e.g., `@myusername/my-lib` or `my-lib`)
|
|
73
|
+
- Repository name
|
|
74
|
+
- GitHub username/org
|
|
75
|
+
- Project description
|
|
76
|
+
- Author name
|
|
77
|
+
|
|
78
|
+
### CLI Mode
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
bun run setup -- [options]
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
| Flag | Short | Description |
|
|
85
|
+
|------|-------|-------------|
|
|
86
|
+
| `--name` | `-n` | Package name (e.g., `@myusername/my-lib`) |
|
|
87
|
+
| `--repo` | `-r` | Repository name (defaults to package name) |
|
|
88
|
+
| `--user` | `-u` | GitHub username/org (auto-detected from `gh`) |
|
|
89
|
+
| `--description` | `-d` | Project description |
|
|
90
|
+
| `--author` | `-a` | Author name |
|
|
91
|
+
| `--yes` | `-y` | Skip confirmation prompts (auto-yes) |
|
|
92
|
+
| `--no-github` | | Skip GitHub repo creation/configuration |
|
|
93
|
+
| `--help` | `-h` | Show help |
|
|
94
|
+
|
|
95
|
+
### What Setup Does
|
|
96
|
+
|
|
97
|
+
1. **Configures files** - Replaces placeholders in `package.json` and `.changeset/config.json`
|
|
98
|
+
2. **Installs dependencies** - Runs `bun install`
|
|
99
|
+
3. **Creates initial commit** - Commits all configured files
|
|
100
|
+
4. **Creates GitHub repo** (if it doesn't exist) - Uses `gh repo create`
|
|
101
|
+
5. **Configures GitHub settings**:
|
|
102
|
+
- Enables workflow permissions for PR creation
|
|
103
|
+
- Sets squash-only merging
|
|
104
|
+
- Enables auto-delete branches
|
|
105
|
+
- Enables auto-merge
|
|
106
|
+
- Configures branch protection rules
|
|
107
|
+
|
|
108
|
+
## Complete Setup Guide
|
|
109
|
+
|
|
110
|
+
This guide walks through the full process of creating a new package and publishing it to npm.
|
|
111
|
+
|
|
112
|
+
### Step 1: Create Repository
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
# Create and clone from template
|
|
116
|
+
gh repo create myusername/my-lib --template nathanvale/bun-typescript-starter --public --clone
|
|
117
|
+
cd my-lib
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Step 2: Run Setup
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
# Interactive mode
|
|
124
|
+
bun run setup
|
|
125
|
+
|
|
126
|
+
# Or non-interactive mode
|
|
127
|
+
bun run setup -- \
|
|
128
|
+
--name "@myusername/my-lib" \
|
|
129
|
+
--description "My awesome library" \
|
|
130
|
+
--author "Your Name" \
|
|
131
|
+
--yes
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### Step 3: Configure NPM Token
|
|
135
|
+
|
|
136
|
+
Before publishing, you need to add your npm token to GitHub secrets.
|
|
137
|
+
|
|
138
|
+
#### Create npm Granular Access Token
|
|
139
|
+
|
|
140
|
+
1. Go to [npmjs.com](https://www.npmjs.com) → Access Tokens → Generate New Token → **Granular Access Token**
|
|
141
|
+
|
|
142
|
+
2. Configure the token:
|
|
143
|
+
- **Token name:** `github-actions-publish` (or any name)
|
|
144
|
+
- **Expiration:** 90 days (maximum for granular tokens)
|
|
145
|
+
- **Packages and scopes:** Select "All packages" for new packages, or specific packages for existing ones
|
|
146
|
+
- **Permissions:** Read and write
|
|
147
|
+
- **IMPORTANT:** Check **"Bypass two-factor authentication for automation"**
|
|
148
|
+
|
|
149
|
+
> Without "Bypass 2FA", CI/CD publishing will fail with "Access token expired or revoked"
|
|
150
|
+
|
|
151
|
+
3. Copy the token (starts with `npm_`)
|
|
152
|
+
|
|
153
|
+
#### Add Token to GitHub Secrets
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
# If you have NPM_TOKEN in your environment
|
|
157
|
+
echo "$NPM_TOKEN" | gh secret set NPM_TOKEN --repo myusername/my-lib
|
|
158
|
+
|
|
159
|
+
# Or set it interactively
|
|
160
|
+
gh secret set NPM_TOKEN --repo myusername/my-lib
|
|
161
|
+
# Paste your token when prompted
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### Step 4: Create Initial Release
|
|
165
|
+
|
|
166
|
+
Create a changeset describing your initial release:
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
# Create a feature branch
|
|
170
|
+
git checkout -b feat/initial-release
|
|
171
|
+
|
|
172
|
+
# Create changeset file
|
|
173
|
+
mkdir -p .changeset
|
|
174
|
+
cat > .changeset/initial-release.md << 'EOF'
|
|
175
|
+
---
|
|
176
|
+
"@myusername/my-lib": minor
|
|
177
|
+
---
|
|
178
|
+
|
|
179
|
+
Initial release
|
|
180
|
+
EOF
|
|
181
|
+
|
|
182
|
+
# Commit and push
|
|
183
|
+
git add .changeset/initial-release.md
|
|
184
|
+
git commit -m "chore: add changeset for initial release"
|
|
185
|
+
git push -u origin feat/initial-release
|
|
186
|
+
|
|
187
|
+
# Create PR
|
|
188
|
+
gh pr create --title "chore: add changeset for initial release" --body "Initial release"
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
### Step 5: Merge and Publish
|
|
192
|
+
|
|
193
|
+
1. **Wait for CI checks** to pass on your PR
|
|
194
|
+
2. **Merge the PR** - This triggers the changesets workflow
|
|
195
|
+
3. **A "Version Packages" PR** will be automatically created
|
|
196
|
+
4. **Merge the Version PR** - This triggers the publish workflow
|
|
197
|
+
5. **Package is published to npm!**
|
|
198
|
+
|
|
199
|
+
```bash
|
|
200
|
+
# Check your package
|
|
201
|
+
npm view @myusername/my-lib
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
### Step 6: Configure OIDC Trusted Publishing (Optional)
|
|
205
|
+
|
|
206
|
+
After the first publish, you can enable token-free publishing via OIDC:
|
|
207
|
+
|
|
208
|
+
1. Go to [npmjs.com](https://www.npmjs.com) → Your Package → Settings → Publishing Access
|
|
209
|
+
2. Click "Add Trusted Publisher"
|
|
210
|
+
3. Configure:
|
|
211
|
+
- **Owner:** Your GitHub username/org
|
|
212
|
+
- **Repository:** Your repo name
|
|
213
|
+
- **Workflow file:** `publish.yml`
|
|
214
|
+
4. Save changes
|
|
215
|
+
5. Optionally remove the `NPM_TOKEN` secret from GitHub
|
|
216
|
+
|
|
217
|
+
Now future releases will publish automatically without any tokens!
|
|
218
|
+
|
|
219
|
+
## NPM Token Setup
|
|
220
|
+
|
|
221
|
+
### Why Granular Tokens?
|
|
222
|
+
|
|
223
|
+
As of December 2024, npm has revoked all classic tokens. You must use **granular access tokens** for CI/CD publishing.
|
|
224
|
+
|
|
225
|
+
### Token Requirements
|
|
226
|
+
|
|
227
|
+
| Setting | Value | Why |
|
|
228
|
+
|---------|-------|-----|
|
|
229
|
+
| Type | Granular Access Token | Classic tokens no longer work |
|
|
230
|
+
| Packages | All packages (for new) or specific | Allows publishing |
|
|
231
|
+
| Permissions | Read and write | Required to publish |
|
|
232
|
+
| **Bypass 2FA** | **Checked** | **Required for CI/CD** |
|
|
233
|
+
|
|
234
|
+
### Common Errors
|
|
235
|
+
|
|
236
|
+
| Error | Cause | Fix |
|
|
237
|
+
|-------|-------|-----|
|
|
238
|
+
| "Access token expired or revoked" | Token doesn't have "Bypass 2FA" | Create new token with 2FA bypass |
|
|
239
|
+
| "E404 Not Found" | Token doesn't have publish permissions | Check token has read/write access |
|
|
240
|
+
| "E403 Forbidden" | Package scope mismatch | Ensure token covers your package scope |
|
|
241
|
+
|
|
242
|
+
## What's Included
|
|
243
|
+
|
|
244
|
+
### CI/CD Workflows
|
|
245
|
+
|
|
246
|
+
| Workflow | Trigger | Purpose |
|
|
247
|
+
|----------|---------|---------|
|
|
248
|
+
| `pr-quality.yml` | PR | Lint, Typecheck, Test with coverage |
|
|
249
|
+
| `publish.yml` | Push to main | Auto-publish via Changesets |
|
|
250
|
+
| `commitlint.yml` | PR | Enforce conventional commits |
|
|
251
|
+
| `pr-title.yml` | PR | Validate PR title format |
|
|
252
|
+
| `security.yml` | Push/Schedule | CodeQL + Trivy scanning |
|
|
253
|
+
| `dependency-review.yml` | PR | Supply chain security |
|
|
254
|
+
| `dependabot-auto-merge.yml` | Dependabot PR | Auto-merge patch updates |
|
|
255
|
+
|
|
256
|
+
### Scripts
|
|
257
|
+
|
|
258
|
+
```bash
|
|
259
|
+
# Development
|
|
260
|
+
bun dev # Watch mode
|
|
261
|
+
bun run build # Build for production
|
|
262
|
+
bun run clean # Remove dist/
|
|
263
|
+
|
|
264
|
+
# Quality
|
|
265
|
+
bun run check # Biome lint + format (write)
|
|
266
|
+
bun run lint # Lint only
|
|
267
|
+
bun run format # Format only
|
|
268
|
+
bun run typecheck # TypeScript type check
|
|
269
|
+
bun run validate # Full quality check (lint + types + build + test)
|
|
270
|
+
|
|
271
|
+
# Testing
|
|
272
|
+
bun test # Run tests
|
|
273
|
+
bun test --watch # Watch mode
|
|
274
|
+
bun run coverage # With coverage report
|
|
275
|
+
|
|
276
|
+
# Publishing
|
|
277
|
+
bun run version:gen # Create changeset interactively
|
|
278
|
+
bun run release # Publish to npm (CI handles this)
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
## Project Structure
|
|
282
|
+
|
|
283
|
+
```
|
|
284
|
+
├── .github/
|
|
285
|
+
│ ├── workflows/ # CI/CD workflows
|
|
286
|
+
│ ├── actions/ # Reusable composite actions
|
|
287
|
+
│ └── scripts/ # CI helper scripts
|
|
288
|
+
├── .husky/ # Git hooks
|
|
289
|
+
├── .changeset/ # Changeset config
|
|
290
|
+
├── src/
|
|
291
|
+
│ └── index.ts # Main entry point
|
|
292
|
+
├── tests/
|
|
293
|
+
│ └── index.test.ts # Example test
|
|
294
|
+
├── biome.json # Biome config
|
|
295
|
+
├── tsconfig.json # TypeScript config
|
|
296
|
+
├── bunup.config.ts # Build config
|
|
297
|
+
└── package.json
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
## Configuration
|
|
301
|
+
|
|
302
|
+
### Biome
|
|
303
|
+
|
|
304
|
+
Configured in `biome.json`:
|
|
305
|
+
- Tab indentation
|
|
306
|
+
- 80 character line width
|
|
307
|
+
- Single quotes
|
|
308
|
+
- Organize imports on save
|
|
309
|
+
|
|
310
|
+
### TypeScript
|
|
311
|
+
|
|
312
|
+
- Strict mode enabled
|
|
313
|
+
- ESM output
|
|
314
|
+
- Source maps and declarations
|
|
315
|
+
|
|
316
|
+
### Changesets
|
|
317
|
+
|
|
318
|
+
- GitHub changelog format
|
|
319
|
+
- Public npm access
|
|
320
|
+
- Provenance enabled
|
|
321
|
+
|
|
322
|
+
## Branch Protection
|
|
323
|
+
|
|
324
|
+
The setup script automatically configures branch protection for `main`:
|
|
325
|
+
|
|
326
|
+
- Require pull request before merging
|
|
327
|
+
- Require status checks to pass ("All checks passed")
|
|
328
|
+
- Require linear history
|
|
329
|
+
- No force pushes
|
|
330
|
+
- No deletions
|
|
331
|
+
|
|
332
|
+
If you need to manually configure it:
|
|
333
|
+
|
|
334
|
+
1. Go to Settings → Branches → Add rule
|
|
335
|
+
2. Branch name pattern: `main`
|
|
336
|
+
3. Enable the settings above
|
|
337
|
+
|
|
338
|
+
## Troubleshooting
|
|
339
|
+
|
|
340
|
+
### Setup script hangs
|
|
341
|
+
|
|
342
|
+
If running in a non-TTY environment (like some CI systems), use CLI flags:
|
|
343
|
+
|
|
344
|
+
```bash
|
|
345
|
+
bun run setup -- --name "my-lib" --description "desc" --author "name" --yes
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
### CI can't create PRs
|
|
349
|
+
|
|
350
|
+
The setup script enables this automatically. If you need to do it manually:
|
|
351
|
+
|
|
352
|
+
```bash
|
|
353
|
+
gh api repos/OWNER/REPO/actions/permissions/workflow \
|
|
354
|
+
--method PUT \
|
|
355
|
+
-f default_workflow_permissions=write \
|
|
356
|
+
-F can_approve_pull_request_reviews=true
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
### Version PR checks don't run
|
|
360
|
+
|
|
361
|
+
Bot-created PRs don't trigger workflows. Push an empty commit:
|
|
362
|
+
|
|
363
|
+
```bash
|
|
364
|
+
git fetch origin
|
|
365
|
+
git checkout changeset-release/main
|
|
366
|
+
git commit --allow-empty -m "chore: trigger CI"
|
|
367
|
+
git push
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
### npm publish fails with 404
|
|
371
|
+
|
|
372
|
+
1. Ensure your npm token has "Bypass 2FA" checked
|
|
373
|
+
2. Ensure token has "Read and write" permissions
|
|
374
|
+
3. Ensure token covers "All packages" (for new packages)
|
|
375
|
+
|
|
376
|
+
## License
|
|
377
|
+
|
|
378
|
+
MIT
|
|
379
|
+
|
|
380
|
+
---
|
|
381
|
+
|
|
382
|
+
Built with [bun-typescript-starter](https://github.com/nathanvale/bun-typescript-starter)
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// @bun
|
|
3
|
+
import {
|
|
4
|
+
createCorrelationId,
|
|
5
|
+
createXApiClient,
|
|
6
|
+
formatReplies,
|
|
7
|
+
formatSearch,
|
|
8
|
+
formatThread,
|
|
9
|
+
formatTimeline,
|
|
10
|
+
formatTweet,
|
|
11
|
+
formatUser,
|
|
12
|
+
initLogger,
|
|
13
|
+
logger
|
|
14
|
+
} from "../shared/chunk-219wrwgz.js";
|
|
15
|
+
|
|
16
|
+
// mcp/index.ts
|
|
17
|
+
import { startServer, tool, z } from "@side-quest/core/mcp";
|
|
18
|
+
import {
|
|
19
|
+
createLoggerAdapter,
|
|
20
|
+
setMcpLogger,
|
|
21
|
+
wrapToolHandler
|
|
22
|
+
} from "@side-quest/core/mcp-response";
|
|
23
|
+
await initLogger();
|
|
24
|
+
var logAdapter = createLoggerAdapter(logger);
|
|
25
|
+
setMcpLogger(logAdapter);
|
|
26
|
+
var bearerToken = process.env.X_BEARER_TOKEN;
|
|
27
|
+
if (!bearerToken) {
|
|
28
|
+
console.error("X_BEARER_TOKEN environment variable is required");
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
var client = createXApiClient({ bearerToken });
|
|
32
|
+
var responseFormatSchema = z.enum(["markdown", "json"]).optional().default("json").describe("Output format: 'markdown' or 'json' (default)");
|
|
33
|
+
var readOnlyAnnotations = {
|
|
34
|
+
readOnlyHint: true,
|
|
35
|
+
destructiveHint: false,
|
|
36
|
+
idempotentHint: true,
|
|
37
|
+
openWorldHint: true
|
|
38
|
+
};
|
|
39
|
+
var handlerOpts = (toolName) => ({
|
|
40
|
+
toolName,
|
|
41
|
+
logger: logAdapter,
|
|
42
|
+
createCid: createCorrelationId
|
|
43
|
+
});
|
|
44
|
+
tool("x_get_tweet", {
|
|
45
|
+
description: "Get a single tweet by ID with author info and engagement metrics",
|
|
46
|
+
inputSchema: {
|
|
47
|
+
tweet_id: z.string().describe("The tweet/post ID"),
|
|
48
|
+
response_format: responseFormatSchema
|
|
49
|
+
},
|
|
50
|
+
annotations: readOnlyAnnotations
|
|
51
|
+
}, wrapToolHandler(async (args, format) => {
|
|
52
|
+
const result = await client.getTweet(args.tweet_id);
|
|
53
|
+
return formatTweet(result, format);
|
|
54
|
+
}, handlerOpts("x_get_tweet")));
|
|
55
|
+
tool("x_get_thread", {
|
|
56
|
+
description: "Get a full conversation thread starting from a tweet ID. Uses search API (7-day window). Returns tweets in chronological reading order.",
|
|
57
|
+
inputSchema: {
|
|
58
|
+
tweet_id: z.string().describe("The root tweet/post ID"),
|
|
59
|
+
max_results: z.number().int().min(1).max(200).optional().default(50).describe("Maximum conversation tweets to fetch (default 50, max 200)"),
|
|
60
|
+
response_format: responseFormatSchema
|
|
61
|
+
},
|
|
62
|
+
annotations: readOnlyAnnotations
|
|
63
|
+
}, wrapToolHandler(async (args, format) => {
|
|
64
|
+
const result = await client.getThread(args.tweet_id, args.max_results ?? 50);
|
|
65
|
+
return formatThread(result, format);
|
|
66
|
+
}, handlerOpts("x_get_thread")));
|
|
67
|
+
tool("x_get_timeline", {
|
|
68
|
+
description: "Get a user's recent tweets by username",
|
|
69
|
+
inputSchema: {
|
|
70
|
+
username: z.string().describe("Twitter username (without @ prefix)"),
|
|
71
|
+
max_results: z.number().int().min(1).max(100).optional().default(10).describe("Number of tweets to fetch (default 10, max 100)"),
|
|
72
|
+
response_format: responseFormatSchema
|
|
73
|
+
},
|
|
74
|
+
annotations: readOnlyAnnotations
|
|
75
|
+
}, wrapToolHandler(async (args, format) => {
|
|
76
|
+
const result = await client.getTimeline(args.username, args.max_results ?? 10);
|
|
77
|
+
return formatTimeline(result, format);
|
|
78
|
+
}, handlerOpts("x_get_timeline")));
|
|
79
|
+
tool("x_search", {
|
|
80
|
+
description: "Search recent tweets (7-day window). Uses Twitter search API \u2014 may require elevated API tier.",
|
|
81
|
+
inputSchema: {
|
|
82
|
+
query: z.string().describe("Search query (supports Twitter search operators)"),
|
|
83
|
+
max_results: z.number().int().min(10).max(100).optional().default(10).describe("Number of results to return (default 10, max 100)"),
|
|
84
|
+
response_format: responseFormatSchema
|
|
85
|
+
},
|
|
86
|
+
annotations: readOnlyAnnotations
|
|
87
|
+
}, wrapToolHandler(async (args, format) => {
|
|
88
|
+
const result = await client.searchRecent(args.query, args.max_results ?? 10);
|
|
89
|
+
return formatSearch(result, format);
|
|
90
|
+
}, handlerOpts("x_get_search")));
|
|
91
|
+
tool("x_get_user", {
|
|
92
|
+
description: "Get a Twitter/X user profile by username \u2014 bio, follower counts, tweet count",
|
|
93
|
+
inputSchema: {
|
|
94
|
+
username: z.string().describe("Twitter username (without @ prefix)"),
|
|
95
|
+
response_format: responseFormatSchema
|
|
96
|
+
},
|
|
97
|
+
annotations: readOnlyAnnotations
|
|
98
|
+
}, wrapToolHandler(async (args, format) => {
|
|
99
|
+
const result = await client.getUser(args.username);
|
|
100
|
+
return formatUser(result, format);
|
|
101
|
+
}, handlerOpts("x_get_user")));
|
|
102
|
+
tool("x_get_replies", {
|
|
103
|
+
description: "Get direct replies to a tweet. Uses search API (7-day window) \u2014 may require elevated API tier.",
|
|
104
|
+
inputSchema: {
|
|
105
|
+
tweet_id: z.string().describe("The tweet/post ID to get replies for"),
|
|
106
|
+
max_results: z.number().int().min(10).max(100).optional().default(20).describe("Maximum replies to fetch (default 20, max 100)"),
|
|
107
|
+
response_format: responseFormatSchema
|
|
108
|
+
},
|
|
109
|
+
annotations: readOnlyAnnotations
|
|
110
|
+
}, wrapToolHandler(async (args, format) => {
|
|
111
|
+
const result = await client.getReplies(args.tweet_id, args.max_results ?? 20);
|
|
112
|
+
return formatReplies(result, format);
|
|
113
|
+
}, handlerOpts("x_get_replies")));
|
|
114
|
+
startServer("x-api", {
|
|
115
|
+
version: "1.0.0",
|
|
116
|
+
fileLogging: {
|
|
117
|
+
enabled: true,
|
|
118
|
+
subsystems: ["mcp"],
|
|
119
|
+
level: "info"
|
|
120
|
+
}
|
|
121
|
+
});
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// src/includes.ts
|
|
3
|
+
function buildMaps(includes) {
|
|
4
|
+
const userMap = new Map(includes?.users?.map((u) => [u.id, u]));
|
|
5
|
+
const tweetMap = new Map(includes?.tweets?.map((t) => [t.id, t]));
|
|
6
|
+
return { userMap, tweetMap };
|
|
7
|
+
}
|
|
8
|
+
function enrichTweet(tweet, userMap, tweetMap) {
|
|
9
|
+
return {
|
|
10
|
+
...tweet,
|
|
11
|
+
author: tweet.author_id ? userMap.get(tweet.author_id) : undefined,
|
|
12
|
+
referenced_tweet_data: tweet.referenced_tweets?.[0] ? tweetMap.get(tweet.referenced_tweets[0].id) : undefined
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
function mergeSingleTweet(response) {
|
|
16
|
+
const { userMap, tweetMap } = buildMaps(response.includes);
|
|
17
|
+
return enrichTweet(response.data, userMap, tweetMap);
|
|
18
|
+
}
|
|
19
|
+
function mergeTweetList(response) {
|
|
20
|
+
if (!response.data)
|
|
21
|
+
return [];
|
|
22
|
+
const { userMap, tweetMap } = buildMaps(response.includes);
|
|
23
|
+
return response.data.map((tweet) => enrichTweet(tweet, userMap, tweetMap));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// src/logger.ts
|
|
27
|
+
import { createPluginLogger } from "@side-quest/core/logging";
|
|
28
|
+
var { initLogger, getSubsystemLogger, createCorrelationId } = createPluginLogger({
|
|
29
|
+
name: "x-api",
|
|
30
|
+
subsystems: ["mcp"]
|
|
31
|
+
});
|
|
32
|
+
var logger = getSubsystemLogger("mcp");
|
|
33
|
+
|
|
34
|
+
// src/thread.ts
|
|
35
|
+
var SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
|
36
|
+
var MAX_THREAD_TWEETS = 200;
|
|
37
|
+
async function buildThread(request, tweetId, maxResults, fields) {
|
|
38
|
+
const cap = Math.min(maxResults, MAX_THREAD_TWEETS);
|
|
39
|
+
const rootParams = new URLSearchParams({
|
|
40
|
+
"tweet.fields": fields.tweetFields,
|
|
41
|
+
expansions: fields.expansions,
|
|
42
|
+
"user.fields": fields.userFields
|
|
43
|
+
});
|
|
44
|
+
const rootResponse = await request(`/tweets/${tweetId}`, rootParams);
|
|
45
|
+
const rootTweet = mergeSingleTweet(rootResponse);
|
|
46
|
+
const conversationId = rootResponse.data.conversation_id ?? rootResponse.data.id;
|
|
47
|
+
let ageWarning;
|
|
48
|
+
if (rootResponse.data.created_at) {
|
|
49
|
+
const tweetAge = Date.now() - new Date(rootResponse.data.created_at).getTime();
|
|
50
|
+
if (tweetAge > SEVEN_DAYS_MS) {
|
|
51
|
+
ageWarning = "Warning: Tweet is older than 7 days. Search API only returns recent replies.";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const allTweets = [rootTweet];
|
|
55
|
+
const seenIds = new Set([tweetId]);
|
|
56
|
+
let nextToken;
|
|
57
|
+
let fetched = 0;
|
|
58
|
+
while (fetched < cap) {
|
|
59
|
+
const pageSize = Math.min(100, cap - fetched);
|
|
60
|
+
const params = new URLSearchParams({
|
|
61
|
+
query: `conversation_id:${conversationId}`,
|
|
62
|
+
max_results: String(Math.max(pageSize, 10)),
|
|
63
|
+
"tweet.fields": fields.tweetFields,
|
|
64
|
+
expansions: fields.expansions,
|
|
65
|
+
"user.fields": fields.userFields
|
|
66
|
+
});
|
|
67
|
+
if (nextToken)
|
|
68
|
+
params.set("next_token", nextToken);
|
|
69
|
+
const page = await request("/tweets/search/recent", params);
|
|
70
|
+
if (page.data) {
|
|
71
|
+
const enriched = mergeTweetList(page);
|
|
72
|
+
for (const tweet of enriched) {
|
|
73
|
+
if (!seenIds.has(tweet.id)) {
|
|
74
|
+
seenIds.add(tweet.id);
|
|
75
|
+
allTweets.push(tweet);
|
|
76
|
+
fetched++;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
nextToken = page.meta?.next_token;
|
|
81
|
+
if (!nextToken || !page.data?.length)
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
allTweets.sort((a, b) => {
|
|
85
|
+
if (!a.created_at || !b.created_at)
|
|
86
|
+
return 0;
|
|
87
|
+
return new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
|
|
88
|
+
});
|
|
89
|
+
return { tweets: allTweets, ageWarning };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// src/client.ts
|
|
93
|
+
import { TimeoutError, withTimeout } from "@side-quest/core/concurrency";
|
|
94
|
+
import { getErrorCategory } from "@side-quest/core/instrumentation";
|
|
95
|
+
import { createCorrelationId as createCorrelationId2 } from "@side-quest/core/logging";
|
|
96
|
+
import { retry } from "@side-quest/core/utils";
|
|
97
|
+
var BASE_URL = "https://api.twitter.com/2";
|
|
98
|
+
var TWEET_FIELDS = "author_id,conversation_id,created_at,in_reply_to_user_id,public_metrics,referenced_tweets";
|
|
99
|
+
var TWEET_EXPANSIONS = "author_id,referenced_tweets.id";
|
|
100
|
+
var USER_FIELDS = "created_at,description,profile_image_url,public_metrics,verified";
|
|
101
|
+
|
|
102
|
+
class TwitterApiError extends Error {
|
|
103
|
+
status;
|
|
104
|
+
category;
|
|
105
|
+
constructor(message, status, category) {
|
|
106
|
+
super(message);
|
|
107
|
+
this.status = status;
|
|
108
|
+
this.category = category;
|
|
109
|
+
this.name = "TwitterApiError";
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
function parseTwitterError(status, body) {
|
|
113
|
+
let detail;
|
|
114
|
+
try {
|
|
115
|
+
const parsed = JSON.parse(body);
|
|
116
|
+
detail = parsed?.detail ?? parsed?.errors?.[0]?.message ?? body;
|
|
117
|
+
} catch {
|
|
118
|
+
detail = body;
|
|
119
|
+
}
|
|
120
|
+
if (status === 429)
|
|
121
|
+
return new TwitterApiError(`Rate limited: ${detail}`, status, "transient");
|
|
122
|
+
if (status === 401)
|
|
123
|
+
return new TwitterApiError(`Auth failed: ${detail}`, status, "configuration");
|
|
124
|
+
if (status === 403)
|
|
125
|
+
return new TwitterApiError(`Forbidden: ${detail} (check API tier)`, status, "configuration");
|
|
126
|
+
if (status === 404)
|
|
127
|
+
return new TwitterApiError(`Not found: ${detail}`, status, "permanent");
|
|
128
|
+
if (status >= 500)
|
|
129
|
+
return new TwitterApiError(`Server error ${status}: ${detail}`, status, "transient");
|
|
130
|
+
return new TwitterApiError(`API error ${status}: ${detail}`, status, "permanent");
|
|
131
|
+
}
|
|
132
|
+
function createXApiClient(config) {
|
|
133
|
+
const { bearerToken, fetchFn = fetch, timeoutMs = 1e4 } = config;
|
|
134
|
+
async function request(endpoint, params) {
|
|
135
|
+
const cid = createCorrelationId2();
|
|
136
|
+
const url = params ? `${BASE_URL}${endpoint}?${params}` : `${BASE_URL}${endpoint}`;
|
|
137
|
+
logger.info`x-api:request:start cid=${cid} endpoint=${endpoint}`;
|
|
138
|
+
const result = await retry(async () => {
|
|
139
|
+
const response = await withTimeout(fetchFn(url, {
|
|
140
|
+
headers: { Authorization: `Bearer ${bearerToken}` }
|
|
141
|
+
}), timeoutMs, `Twitter API timeout: ${endpoint}`);
|
|
142
|
+
const remaining = response.headers.get("x-rate-limit-remaining");
|
|
143
|
+
const reset = response.headers.get("x-rate-limit-reset");
|
|
144
|
+
if (remaining && Number(remaining) < 3) {
|
|
145
|
+
logger.warn`x-api:rateLimit:low cid=${cid} remaining=${remaining} resetAt=${reset}`;
|
|
146
|
+
}
|
|
147
|
+
if (!response.ok) {
|
|
148
|
+
const errorBody = await response.text();
|
|
149
|
+
throw parseTwitterError(response.status, errorBody);
|
|
150
|
+
}
|
|
151
|
+
return response.json();
|
|
152
|
+
}, {
|
|
153
|
+
maxAttempts: 3,
|
|
154
|
+
initialDelay: 1000,
|
|
155
|
+
shouldRetry: (error) => {
|
|
156
|
+
if (error instanceof TimeoutError)
|
|
157
|
+
return true;
|
|
158
|
+
if (error instanceof TwitterApiError)
|
|
159
|
+
return error.category === "transient";
|
|
160
|
+
return getErrorCategory(error) === "transient";
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
logger.info`x-api:request:complete cid=${cid} endpoint=${endpoint}`;
|
|
164
|
+
return result;
|
|
165
|
+
}
|
|
166
|
+
function defaultTweetParams() {
|
|
167
|
+
return new URLSearchParams({
|
|
168
|
+
"tweet.fields": TWEET_FIELDS,
|
|
169
|
+
expansions: TWEET_EXPANSIONS,
|
|
170
|
+
"user.fields": USER_FIELDS
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
function defaultUserParams() {
|
|
174
|
+
return new URLSearchParams({
|
|
175
|
+
"user.fields": USER_FIELDS
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
return {
|
|
179
|
+
async getTweet(id) {
|
|
180
|
+
const response = await request(`/tweets/${id}`, defaultTweetParams());
|
|
181
|
+
return mergeSingleTweet(response);
|
|
182
|
+
},
|
|
183
|
+
async getThread(tweetId, maxResults) {
|
|
184
|
+
return buildThread(request, tweetId, maxResults, {
|
|
185
|
+
tweetFields: TWEET_FIELDS,
|
|
186
|
+
expansions: TWEET_EXPANSIONS,
|
|
187
|
+
userFields: USER_FIELDS
|
|
188
|
+
});
|
|
189
|
+
},
|
|
190
|
+
async getTimeline(username, maxResults) {
|
|
191
|
+
const userResponse = await request(`/users/by/username/${username}`, defaultUserParams());
|
|
192
|
+
const params = defaultTweetParams();
|
|
193
|
+
params.set("max_results", String(Math.min(maxResults, 100)));
|
|
194
|
+
const timeline = await request(`/users/${userResponse.data.id}/tweets`, params);
|
|
195
|
+
return {
|
|
196
|
+
user: userResponse.data,
|
|
197
|
+
tweets: mergeTweetList(timeline)
|
|
198
|
+
};
|
|
199
|
+
},
|
|
200
|
+
async searchRecent(query, maxResults) {
|
|
201
|
+
const params = defaultTweetParams();
|
|
202
|
+
params.set("query", query);
|
|
203
|
+
params.set("max_results", String(Math.min(maxResults, 100)));
|
|
204
|
+
const response = await request("/tweets/search/recent", params);
|
|
205
|
+
return {
|
|
206
|
+
query,
|
|
207
|
+
tweets: mergeTweetList(response),
|
|
208
|
+
resultCount: response.meta?.result_count ?? 0
|
|
209
|
+
};
|
|
210
|
+
},
|
|
211
|
+
async getUser(username) {
|
|
212
|
+
const response = await request(`/users/by/username/${username}`, defaultUserParams());
|
|
213
|
+
return response.data;
|
|
214
|
+
},
|
|
215
|
+
async getReplies(tweetId, maxResults) {
|
|
216
|
+
const original = await request(`/tweets/${tweetId}`, defaultTweetParams());
|
|
217
|
+
const enrichedOriginal = mergeSingleTweet(original);
|
|
218
|
+
const conversationId = original.data.conversation_id ?? original.data.id;
|
|
219
|
+
const params = defaultTweetParams();
|
|
220
|
+
params.set("query", `conversation_id:${conversationId} in_reply_to_tweet_id:${tweetId}`);
|
|
221
|
+
params.set("max_results", String(Math.min(maxResults, 100)));
|
|
222
|
+
const response = await request("/tweets/search/recent", params);
|
|
223
|
+
return {
|
|
224
|
+
originalTweet: enrichedOriginal,
|
|
225
|
+
replies: mergeTweetList(response),
|
|
226
|
+
resultCount: response.meta?.result_count ?? 0
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// src/formatters.ts
|
|
233
|
+
import { ResponseFormat } from "@side-quest/core/mcp-response";
|
|
234
|
+
function formatTweetMarkdown(tweet) {
|
|
235
|
+
const lines = [];
|
|
236
|
+
const author = tweet.author;
|
|
237
|
+
if (author) {
|
|
238
|
+
lines.push(`**@${author.username}** (${author.name})`);
|
|
239
|
+
}
|
|
240
|
+
lines.push(tweet.text);
|
|
241
|
+
if (tweet.created_at) {
|
|
242
|
+
lines.push(`*${new Date(tweet.created_at).toLocaleString()}*`);
|
|
243
|
+
}
|
|
244
|
+
const m = tweet.public_metrics;
|
|
245
|
+
if (m) {
|
|
246
|
+
const parts = [];
|
|
247
|
+
if (m.like_count > 0)
|
|
248
|
+
parts.push(`${m.like_count} likes`);
|
|
249
|
+
if (m.retweet_count > 0)
|
|
250
|
+
parts.push(`${m.retweet_count} retweets`);
|
|
251
|
+
if (m.reply_count > 0)
|
|
252
|
+
parts.push(`${m.reply_count} replies`);
|
|
253
|
+
if (m.quote_count > 0)
|
|
254
|
+
parts.push(`${m.quote_count} quotes`);
|
|
255
|
+
if (parts.length > 0)
|
|
256
|
+
lines.push(parts.join(" | "));
|
|
257
|
+
}
|
|
258
|
+
return lines.join(`
|
|
259
|
+
`);
|
|
260
|
+
}
|
|
261
|
+
function formatTweet(tweet, format) {
|
|
262
|
+
if (format === ResponseFormat.JSON) {
|
|
263
|
+
return JSON.stringify(tweet, null, 2);
|
|
264
|
+
}
|
|
265
|
+
return formatTweetMarkdown(tweet);
|
|
266
|
+
}
|
|
267
|
+
function formatThread(result, format) {
|
|
268
|
+
if (format === ResponseFormat.JSON) {
|
|
269
|
+
return JSON.stringify(result, null, 2);
|
|
270
|
+
}
|
|
271
|
+
const lines = [];
|
|
272
|
+
if (result.ageWarning) {
|
|
273
|
+
lines.push(`> ${result.ageWarning}`);
|
|
274
|
+
lines.push("");
|
|
275
|
+
}
|
|
276
|
+
lines.push(`**Thread** (${result.tweets.length} tweets)`);
|
|
277
|
+
lines.push("---");
|
|
278
|
+
for (const tweet of result.tweets) {
|
|
279
|
+
lines.push(formatTweetMarkdown(tweet));
|
|
280
|
+
lines.push("---");
|
|
281
|
+
}
|
|
282
|
+
return lines.join(`
|
|
283
|
+
`);
|
|
284
|
+
}
|
|
285
|
+
function formatTimeline(result, format) {
|
|
286
|
+
if (format === ResponseFormat.JSON) {
|
|
287
|
+
return JSON.stringify(result, null, 2);
|
|
288
|
+
}
|
|
289
|
+
const lines = [];
|
|
290
|
+
const u = result.user;
|
|
291
|
+
lines.push(`**@${u.username}** (${u.name})`);
|
|
292
|
+
if (u.description)
|
|
293
|
+
lines.push(u.description);
|
|
294
|
+
if (u.public_metrics) {
|
|
295
|
+
lines.push(`${u.public_metrics.followers_count} followers | ${u.public_metrics.following_count} following | ${u.public_metrics.tweet_count} tweets`);
|
|
296
|
+
}
|
|
297
|
+
lines.push("");
|
|
298
|
+
lines.push(`**Recent tweets** (${result.tweets.length})`);
|
|
299
|
+
lines.push("---");
|
|
300
|
+
for (const tweet of result.tweets) {
|
|
301
|
+
lines.push(formatTweetMarkdown(tweet));
|
|
302
|
+
lines.push("---");
|
|
303
|
+
}
|
|
304
|
+
return lines.join(`
|
|
305
|
+
`);
|
|
306
|
+
}
|
|
307
|
+
function formatSearch(result, format) {
|
|
308
|
+
if (format === ResponseFormat.JSON) {
|
|
309
|
+
return JSON.stringify(result, null, 2);
|
|
310
|
+
}
|
|
311
|
+
const lines = [];
|
|
312
|
+
lines.push(`**Search:** "${result.query}" (${result.resultCount} results)`);
|
|
313
|
+
lines.push("---");
|
|
314
|
+
for (const tweet of result.tweets) {
|
|
315
|
+
lines.push(formatTweetMarkdown(tweet));
|
|
316
|
+
lines.push("---");
|
|
317
|
+
}
|
|
318
|
+
return lines.join(`
|
|
319
|
+
`);
|
|
320
|
+
}
|
|
321
|
+
function formatUser(user, format) {
|
|
322
|
+
if (format === ResponseFormat.JSON) {
|
|
323
|
+
return JSON.stringify(user, null, 2);
|
|
324
|
+
}
|
|
325
|
+
const lines = [];
|
|
326
|
+
lines.push(`**@${user.username}** (${user.name})`);
|
|
327
|
+
if (user.description)
|
|
328
|
+
lines.push(user.description);
|
|
329
|
+
if (user.created_at) {
|
|
330
|
+
lines.push(`Joined: ${new Date(user.created_at).toLocaleDateString()}`);
|
|
331
|
+
}
|
|
332
|
+
if (user.public_metrics) {
|
|
333
|
+
const m = user.public_metrics;
|
|
334
|
+
lines.push(`${m.followers_count} followers | ${m.following_count} following | ${m.tweet_count} tweets`);
|
|
335
|
+
}
|
|
336
|
+
return lines.join(`
|
|
337
|
+
`);
|
|
338
|
+
}
|
|
339
|
+
function formatReplies(result, format) {
|
|
340
|
+
if (format === ResponseFormat.JSON) {
|
|
341
|
+
return JSON.stringify(result, null, 2);
|
|
342
|
+
}
|
|
343
|
+
const lines = [];
|
|
344
|
+
lines.push("**Original tweet:**");
|
|
345
|
+
lines.push(formatTweetMarkdown(result.originalTweet));
|
|
346
|
+
lines.push("");
|
|
347
|
+
lines.push(`**Replies** (${result.resultCount})`);
|
|
348
|
+
lines.push("---");
|
|
349
|
+
for (const reply of result.replies) {
|
|
350
|
+
lines.push(formatTweetMarkdown(reply));
|
|
351
|
+
lines.push("---");
|
|
352
|
+
}
|
|
353
|
+
return lines.join(`
|
|
354
|
+
`);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export { mergeSingleTweet, mergeTweetList, initLogger, createCorrelationId, logger, buildThread, TwitterApiError, createXApiClient, formatTweet, formatThread, formatTimeline, formatSearch, formatUser, formatReplies };
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Twitter/X API v2 types.
|
|
3
|
+
*
|
|
4
|
+
* Covers tweet, user, and includes structures returned by the v2 endpoints.
|
|
5
|
+
* Only models the fields we actually request via tweet.fields / user.fields.
|
|
6
|
+
*/
|
|
7
|
+
interface Tweet {
|
|
8
|
+
id: string;
|
|
9
|
+
text: string;
|
|
10
|
+
author_id?: string;
|
|
11
|
+
conversation_id?: string;
|
|
12
|
+
created_at?: string;
|
|
13
|
+
in_reply_to_user_id?: string;
|
|
14
|
+
referenced_tweets?: ReferencedTweet[];
|
|
15
|
+
public_metrics?: TweetPublicMetrics;
|
|
16
|
+
}
|
|
17
|
+
interface ReferencedTweet {
|
|
18
|
+
type: "retweeted" | "quoted" | "replied_to";
|
|
19
|
+
id: string;
|
|
20
|
+
}
|
|
21
|
+
interface TweetPublicMetrics {
|
|
22
|
+
retweet_count: number;
|
|
23
|
+
reply_count: number;
|
|
24
|
+
like_count: number;
|
|
25
|
+
quote_count: number;
|
|
26
|
+
bookmark_count?: number;
|
|
27
|
+
impression_count?: number;
|
|
28
|
+
}
|
|
29
|
+
interface User {
|
|
30
|
+
id: string;
|
|
31
|
+
name: string;
|
|
32
|
+
username: string;
|
|
33
|
+
description?: string;
|
|
34
|
+
created_at?: string;
|
|
35
|
+
profile_image_url?: string;
|
|
36
|
+
verified?: boolean;
|
|
37
|
+
public_metrics?: UserPublicMetrics;
|
|
38
|
+
}
|
|
39
|
+
interface UserPublicMetrics {
|
|
40
|
+
followers_count: number;
|
|
41
|
+
following_count: number;
|
|
42
|
+
tweet_count: number;
|
|
43
|
+
listed_count: number;
|
|
44
|
+
}
|
|
45
|
+
interface TwitterIncludes {
|
|
46
|
+
users?: User[];
|
|
47
|
+
tweets?: Tweet[];
|
|
48
|
+
}
|
|
49
|
+
interface TwitterV2Response<T> {
|
|
50
|
+
data: T;
|
|
51
|
+
includes?: TwitterIncludes;
|
|
52
|
+
}
|
|
53
|
+
interface TwitterV2ListResponse<T> {
|
|
54
|
+
data?: T[];
|
|
55
|
+
includes?: TwitterIncludes;
|
|
56
|
+
meta?: TwitterMeta;
|
|
57
|
+
}
|
|
58
|
+
interface TwitterMeta {
|
|
59
|
+
result_count: number;
|
|
60
|
+
newest_id?: string;
|
|
61
|
+
oldest_id?: string;
|
|
62
|
+
next_token?: string;
|
|
63
|
+
}
|
|
64
|
+
interface EnrichedTweet extends Tweet {
|
|
65
|
+
author?: User;
|
|
66
|
+
referenced_tweet_data?: Tweet;
|
|
67
|
+
}
|
|
68
|
+
interface ThreadResult {
|
|
69
|
+
tweets: EnrichedTweet[];
|
|
70
|
+
ageWarning?: string;
|
|
71
|
+
}
|
|
72
|
+
interface TimelineResult {
|
|
73
|
+
user: User;
|
|
74
|
+
tweets: EnrichedTweet[];
|
|
75
|
+
}
|
|
76
|
+
interface SearchResult {
|
|
77
|
+
query: string;
|
|
78
|
+
tweets: EnrichedTweet[];
|
|
79
|
+
resultCount: number;
|
|
80
|
+
}
|
|
81
|
+
interface RepliesResult {
|
|
82
|
+
originalTweet: EnrichedTweet;
|
|
83
|
+
replies: EnrichedTweet[];
|
|
84
|
+
resultCount: number;
|
|
85
|
+
}
|
|
86
|
+
declare class TwitterApiError extends Error {
|
|
87
|
+
readonly status: number;
|
|
88
|
+
readonly category: "transient" | "permanent" | "configuration";
|
|
89
|
+
constructor(message: string, status: number, category: "transient" | "permanent" | "configuration");
|
|
90
|
+
}
|
|
91
|
+
interface XApiClientConfig {
|
|
92
|
+
bearerToken: string;
|
|
93
|
+
/** Injectable fetch for testing — no global mock needed */
|
|
94
|
+
fetchFn?: typeof fetch;
|
|
95
|
+
/** Request timeout in ms (default: 10_000) */
|
|
96
|
+
timeoutMs?: number;
|
|
97
|
+
}
|
|
98
|
+
/** Create a Twitter/X API v2 client with retry, timeout, and rate limit awareness. */
|
|
99
|
+
declare function createXApiClient(config: XApiClientConfig): {
|
|
100
|
+
/** Fetch a single tweet by ID with author and metrics. */
|
|
101
|
+
getTweet(id: string): Promise<EnrichedTweet>;
|
|
102
|
+
/** Reconstruct a conversation thread with pagination. */
|
|
103
|
+
getThread(tweetId: string, maxResults: number): Promise<ThreadResult>;
|
|
104
|
+
/** Fetch a user's recent tweets. */
|
|
105
|
+
getTimeline(username: string, maxResults: number): Promise<TimelineResult>;
|
|
106
|
+
/** Search recent tweets (7-day window). */
|
|
107
|
+
searchRecent(query: string, maxResults: number): Promise<SearchResult>;
|
|
108
|
+
/** Fetch a user profile by username. */
|
|
109
|
+
getUser(username: string): Promise<User>;
|
|
110
|
+
/** Fetch direct replies to a tweet. */
|
|
111
|
+
getReplies(tweetId: string, maxResults: number): Promise<RepliesResult>;
|
|
112
|
+
};
|
|
113
|
+
type XApiClient = ReturnType<typeof createXApiClient>;
|
|
114
|
+
import { ResponseFormat } from "@side-quest/core/mcp-response";
|
|
115
|
+
/** Format a single enriched tweet result. */
|
|
116
|
+
declare function formatTweet(tweet: EnrichedTweet, format: ResponseFormat): string;
|
|
117
|
+
/** Format a thread result. */
|
|
118
|
+
declare function formatThread(result: ThreadResult, format: ResponseFormat): string;
|
|
119
|
+
/** Format a timeline result. */
|
|
120
|
+
declare function formatTimeline(result: TimelineResult, format: ResponseFormat): string;
|
|
121
|
+
/** Format a search result. */
|
|
122
|
+
declare function formatSearch(result: SearchResult, format: ResponseFormat): string;
|
|
123
|
+
/** Format a user profile. */
|
|
124
|
+
declare function formatUser(user: User, format: ResponseFormat): string;
|
|
125
|
+
/** Format a replies result. */
|
|
126
|
+
declare function formatReplies(result: RepliesResult, format: ResponseFormat): string;
|
|
127
|
+
/**
|
|
128
|
+
* Merge includes into a single tweet response.
|
|
129
|
+
*/
|
|
130
|
+
declare function mergeSingleTweet(response: TwitterV2Response<Tweet>): EnrichedTweet;
|
|
131
|
+
/**
|
|
132
|
+
* Merge includes into a list of tweets.
|
|
133
|
+
*/
|
|
134
|
+
declare function mergeTweetList(response: TwitterV2ListResponse<Tweet>): EnrichedTweet[];
|
|
135
|
+
/** Generic request function matching the client's internal shape. */
|
|
136
|
+
type RequestFn = <T>(endpoint: string, params?: URLSearchParams) => Promise<T>;
|
|
137
|
+
interface ThreadFieldParams {
|
|
138
|
+
tweetFields: string;
|
|
139
|
+
expansions: string;
|
|
140
|
+
userFields: string;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Build a conversation thread from a root tweet ID.
|
|
144
|
+
*
|
|
145
|
+
* 1. Fetches the root tweet to get conversation_id
|
|
146
|
+
* 2. Warns if tweet is older than 7 days (search won't find old replies)
|
|
147
|
+
* 3. Paginates through search results for the conversation
|
|
148
|
+
* 4. Sorts chronologically for reading order
|
|
149
|
+
*/
|
|
150
|
+
declare function buildThread(request: RequestFn, tweetId: string, maxResults: number, fields: ThreadFieldParams): Promise<ThreadResult>;
|
|
151
|
+
export { mergeTweetList, mergeSingleTweet, formatUser, formatTweet, formatTimeline, formatThread, formatSearch, formatReplies, createXApiClient, buildThread, XApiClientConfig, XApiClient, UserPublicMetrics, User, TwitterV2Response, TwitterV2ListResponse, TwitterMeta, TwitterIncludes, TwitterApiError, TweetPublicMetrics, Tweet, TimelineResult, ThreadResult, ThreadFieldParams, SearchResult, RequestFn, RepliesResult, ReferencedTweet, EnrichedTweet };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
import {
|
|
3
|
+
TwitterApiError,
|
|
4
|
+
buildThread,
|
|
5
|
+
createXApiClient,
|
|
6
|
+
formatReplies,
|
|
7
|
+
formatSearch,
|
|
8
|
+
formatThread,
|
|
9
|
+
formatTimeline,
|
|
10
|
+
formatTweet,
|
|
11
|
+
formatUser,
|
|
12
|
+
mergeSingleTweet,
|
|
13
|
+
mergeTweetList
|
|
14
|
+
} from "../shared/chunk-219wrwgz.js";
|
|
15
|
+
export {
|
|
16
|
+
mergeTweetList,
|
|
17
|
+
mergeSingleTweet,
|
|
18
|
+
formatUser,
|
|
19
|
+
formatTweet,
|
|
20
|
+
formatTimeline,
|
|
21
|
+
formatThread,
|
|
22
|
+
formatSearch,
|
|
23
|
+
formatReplies,
|
|
24
|
+
createXApiClient,
|
|
25
|
+
buildThread,
|
|
26
|
+
TwitterApiError
|
|
27
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@side-quest/x-api",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "Read-only Twitter/X API v2 — tweets, threads, timelines, user profiles",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "Nathan Vale",
|
|
7
|
+
"url": "https://github.com/nathanvale"
|
|
8
|
+
},
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"type": "module",
|
|
11
|
+
"main": "./dist/src/index.js",
|
|
12
|
+
"types": "./dist/src/index.d.ts",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"types": "./dist/src/index.d.ts",
|
|
16
|
+
"import": "./dist/src/index.js"
|
|
17
|
+
},
|
|
18
|
+
"./mcp": {
|
|
19
|
+
"types": "./dist/mcp/index.d.ts",
|
|
20
|
+
"import": "./dist/mcp/index.js"
|
|
21
|
+
},
|
|
22
|
+
"./package.json": "./package.json"
|
|
23
|
+
},
|
|
24
|
+
"bin": {
|
|
25
|
+
"x-api-mcp": "./dist/mcp/index.js"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"dist/**",
|
|
29
|
+
"README.md",
|
|
30
|
+
"LICENSE",
|
|
31
|
+
"CHANGELOG.md"
|
|
32
|
+
],
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "git+https://github.com/nathanvale/side-quest-x-api.git"
|
|
36
|
+
},
|
|
37
|
+
"bugs": {
|
|
38
|
+
"url": "https://github.com/nathanvale/side-quest-x-api/issues"
|
|
39
|
+
},
|
|
40
|
+
"homepage": "https://github.com/nathanvale/side-quest-x-api#readme",
|
|
41
|
+
"keywords": [
|
|
42
|
+
"twitter",
|
|
43
|
+
"x",
|
|
44
|
+
"api",
|
|
45
|
+
"mcp",
|
|
46
|
+
"tweets",
|
|
47
|
+
"read-only",
|
|
48
|
+
"typescript",
|
|
49
|
+
"bun"
|
|
50
|
+
],
|
|
51
|
+
"engines": {
|
|
52
|
+
"node": ">=22.20"
|
|
53
|
+
},
|
|
54
|
+
"publishConfig": {
|
|
55
|
+
"access": "public",
|
|
56
|
+
"provenance": true
|
|
57
|
+
},
|
|
58
|
+
"sideEffects": false,
|
|
59
|
+
"scripts": {
|
|
60
|
+
"build": "bunx bunup",
|
|
61
|
+
"check": "biome check --write .",
|
|
62
|
+
"check:publint": "publint",
|
|
63
|
+
"check:types": "attw --pack",
|
|
64
|
+
"clean": "rimraf dist 2>/dev/null || true",
|
|
65
|
+
"coverage": "bun test --coverage",
|
|
66
|
+
"dev": "bun --watch src/index.ts",
|
|
67
|
+
"format": "biome format --write .",
|
|
68
|
+
"format:check": "biome format .",
|
|
69
|
+
"hygiene": "bun run check:publint && bun run check:types",
|
|
70
|
+
"lint": "biome lint .",
|
|
71
|
+
"lint:fix": "biome lint --write .",
|
|
72
|
+
"lint:scripts": "shellcheck .github/scripts/*.sh",
|
|
73
|
+
"lint:workflows": "actionlint -color -verbose",
|
|
74
|
+
"pack:dry": "mkdir -p .pack && bun pm pack --destination .pack --ignore-scripts && ls -lah .pack && tar -tf .pack/*.tgz | sort | sed 's/^/ - /'",
|
|
75
|
+
"pre:enter:beta": "changeset pre enter beta",
|
|
76
|
+
"pre:enter:next": "changeset pre enter next",
|
|
77
|
+
"pre:enter:rc": "changeset pre enter rc",
|
|
78
|
+
"pre:exit": "changeset pre exit",
|
|
79
|
+
"prepare": "husky",
|
|
80
|
+
"publish:pre": "changeset publish --provenance",
|
|
81
|
+
"quality-check:ci": "biome check . && bun run typecheck",
|
|
82
|
+
"release": "changeset publish --provenance",
|
|
83
|
+
"release:snapshot:canary": "changeset version --snapshot canary && changeset publish --tag canary",
|
|
84
|
+
"security:audit": "npm audit",
|
|
85
|
+
"test": "bun test --recursive",
|
|
86
|
+
"test:ci": "TF_BUILD=true bun test --recursive",
|
|
87
|
+
"test:coverage": "bun test --coverage",
|
|
88
|
+
"test:watch": "bun test --watch",
|
|
89
|
+
"typecheck": "tsc -p tsconfig.eslint.json --noEmit",
|
|
90
|
+
"validate": "bun run lint && bun run typecheck && bun run build && TF_BUILD=true bun run test",
|
|
91
|
+
"version:gen": "bun run scripts/version-gen.ts",
|
|
92
|
+
"version:pre": "changeset version",
|
|
93
|
+
"watch:types": "tsc -p tsconfig.eslint.json --noEmit --watch"
|
|
94
|
+
},
|
|
95
|
+
"dependencies": {
|
|
96
|
+
"@side-quest/core": "^0.1.1"
|
|
97
|
+
},
|
|
98
|
+
"devDependencies": {
|
|
99
|
+
"@arethetypeswrong/cli": "^0.18.2",
|
|
100
|
+
"@biomejs/biome": "^2.3.7",
|
|
101
|
+
"@changesets/changelog-github": "^0.5.1",
|
|
102
|
+
"@changesets/cli": "^2.29.7",
|
|
103
|
+
"@commitlint/cli": "^20.1.0",
|
|
104
|
+
"@commitlint/config-conventional": "^20.0.0",
|
|
105
|
+
"@types/node": "^24.8.1",
|
|
106
|
+
"bun-types": "^1.3.3",
|
|
107
|
+
"bunup": "^0.16.10",
|
|
108
|
+
"husky": "^9.1.7",
|
|
109
|
+
"lint-staged": "^15.2.10",
|
|
110
|
+
"publint": "^0.3.15",
|
|
111
|
+
"rimraf": "^6.0.1",
|
|
112
|
+
"typescript": "^5.9.3"
|
|
113
|
+
},
|
|
114
|
+
"lint-staged": {
|
|
115
|
+
"*.{ts,tsx,js,jsx,mts,cts,json}": [
|
|
116
|
+
"biome check --write"
|
|
117
|
+
]
|
|
118
|
+
}
|
|
119
|
+
}
|