@straitlink/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 CHANGED
@@ -15,12 +15,13 @@ A tap on a Strait link goes straight to the exact screen in your app (Straight),
15
15
  claude mcp add strait --env STRAIT_SECRET_KEY=st_live_your_key -- npx -y @straitlink/mcp
16
16
  ```
17
17
 
18
- Use Strait from AI tools such as Claude Code, Cursor and Claude Desktop: create deep links, change them, read
19
- analytics, check App Links / Universal Links files, see where taps on a link go, and search the Strait docs.
18
+ Use Strait from AI tools such as Claude Code, Cursor and Claude Desktop: create deep links, change or turn them off,
19
+ read analytics, usage and referrals, check App Links / Universal Links files, see where taps on a link go, look after
20
+ webhooks (deliveries, test, redeliver) and search the Strait docs.
20
21
 
21
- It is a local stdio server with 10 tools. Three of them write (`create_link`, `batch_create_links`,
22
- `update_link`); the other seven only read. Eight need your secret key; `check_app_links`, `search_docs` and demo
23
- `trace_tap` work without one. Other deep-link vendors also ship MCP servers that can create links, so this README
22
+ It is a local stdio server with 16 tools. Six of them write (`create_link`, `batch_create_links`, `update_link`,
23
+ `deactivate_link`, `test_webhook`, `redeliver_webhook_delivery`); the other ten only read. Fourteen need your secret
24
+ key; `check_app_links`, `search_docs` and demo `trace_tap` work without one. Other deep-link vendors also ship MCP servers that can create links, so this README
24
25
  makes no "first" or "only" claim; it describes what this server does today.
25
26
 
26
27
  **iPhone is in beta.** iPhone install matching (deferred deep linking on iOS) is in beta; Android uses the Play
@@ -44,10 +45,19 @@ workspaces. The tools report iPhone results the same way, but treat iPhone insta
44
45
  | `check_app_links` | Check a domain's `assetlinks.json` and `apple-app-site-association`, with a fix for each problem | none |
45
46
  | `simulate_tap` | Dry-run one tap from one device or app (nothing recorded or billed) | secret |
46
47
  | `trace_tap` | Walk a link through WhatsApp, Instagram, Facebook, Chrome, Safari, iPad, computer and preview bots; or read a Live Tap Trace from the website demo | secret (link) / none (demo token) |
48
+ | `get_usage` | This month's taps and new links against the plan, `level` (ok / warn / over) and `new_links_paused` (`GET /v1/usage`) | secret |
49
+ | `get_referrals` | Referral report: taps and referred installs per code (`GET /v1/referrals`, a preview: says "not switched on yet" until it is) | secret |
50
+ | `list_webhook_deliveries` | A webhook's deliveries (status, attempts, last HTTP status, next retry; no payloads). Without `webhookId`: the workspace's webhooks (id, receiver site, events) | secret |
51
+ | `test_webhook` | Send one `ping` to a webhook now and see the receiver's status and time (not stored, not retried) | secret |
52
+ | `redeliver_webhook_delivery` | Send a failed delivery again (same `X-Strait-Delivery` id). **Asks for `confirm: true`** | secret |
53
+ | `deactivate_link` | Turn a link off: taps then get the "link not found" page. **Asks for `confirm: true`**; the API can't turn it back on yet | secret |
47
54
  | `search_docs` | Search the Strait docs offline (bundled index) | none |
48
55
 
49
- Every tool carries MCP annotations (`readOnlyHint`, `destructiveHint`, …). Only `update_link` is destructive.
50
- `create_link` and `batch_create_links` write new links but never change existing ones. There is no delete tool.
56
+ Every tool carries MCP annotations (`readOnlyHint`, `destructiveHint`, …) and its description starts with
57
+ "Read-only." or "Write." for the 0.2 tools. `update_link` and `deactivate_link` are destructive; they and
58
+ `redeliver_webhook_delivery` show a preview first and act only with `confirm: true`. `create_link` and
59
+ `batch_create_links` write new links but never change existing ones. There is no delete tool: a deactivated link
60
+ keeps its history.
51
61
 
52
62
  ## Keys
53
63
 
@@ -57,6 +67,8 @@ links, so:
57
67
  - Create a separate key for your AI tools, so you can revoke it on its own.
58
68
  - Keep it in the MCP client's config or environment, never in app or website code.
59
69
  - A publishable key (`st_pub_live_…`) does not work here: it is for the SDKs in your apps.
70
+ - The server sends the key only as an `Authorization: Bearer` header to `STRAIT_API_URL`, never logs it, and
71
+ replaces anything shaped like a secret key in tool output or errors with `st_…[redacted]`.
60
72
 
61
73
  Without a key, `check_app_links`, `search_docs` and demo `trace_tap` still work.
62
74
 
@@ -109,6 +121,10 @@ In Cursor or Claude Desktop use `"command": "node", "args": ["/absolute/path/to/
109
121
  - "My links open the browser instead of the app. Check hilltop.strait.link and shop.hilltop.example."
110
122
  - "What happens when someone taps `summer` inside Instagram on an iPhone?"
111
123
  - "How do I receive links in Expo Router?"
124
+ - "How much of this month's plan have we used?"
125
+ - "My webhook stopped firing. Show me its last deliveries, send it a test, and resend the failed ones."
126
+ - "Turn off the `spring-sale` link." (you'll see a preview first)
127
+ - "Which referral codes brought the most installs in October?"
112
128
 
113
129
  Hilltop Shoes (`hilltop.strait.link`, `*.hilltop.example`) is a made-up shop used in examples.
114
130
 
@@ -130,8 +146,19 @@ Hilltop Shoes (`hilltop.strait.link`, `*.hilltop.example`) is a made-up shop use
130
146
  - `remotes` is left out until `mcp.strait.link` is deployed; add
131
147
  `{"type": "streamable-http", "url": "https://mcp.strait.link/mcp"}` then. Add `"repository": {"url": "https://github.com/bazingga08/strait-mcp", "source": "github"}`
132
148
  once the GitHub repo exists.
133
- - Order when the Strait team is ready: create the repo, publish `@straitlink/mcp` to npm, add the TXT record, then
134
- `mcp-publisher publish`.
149
+ - Order when the Strait team is ready: create the repo, publish `@straitlink/mcp` to npm (done: 0.1.0 on 15 Oct,
150
+ `mcpName` matches), add the TXT record, then run the **MCP registry** workflow (`.github/workflows/registry.yml`).
151
+ Without the `MCP_DNS_PRIVATE_KEY` secret it only validates `server.json` (schema, name, version, npm) and stops.
152
+
153
+ ### Founder steps (about 10 minutes, after the GitHub repo exists)
154
+
155
+ 1. Claude makes a key pair on the Mac (`openssl genpkey -algorithm ed25519`) and shows you only the **public** part.
156
+ 2. In the DNS settings for **straitlink.in**, add a **TXT** record on the root (`@`) with the value
157
+ `v=MCPv1; k=ed25519; p=<public key>`. Save. It can take up to an hour to appear.
158
+ 3. In GitHub, repo **strait-mcp → Settings → Secrets → Actions**, add `MCP_DNS_PRIVATE_KEY` with the private key
159
+ (Claude puts it there if you allow secret writes).
160
+ 4. Actions → **MCP registry** → Run workflow. When it's green, search "Strait" on
161
+ registry.modelcontextprotocol.io: that's how you know it worked.
135
162
 
136
163
  ## Licence
137
164
 
package/dist/client.js CHANGED
@@ -91,6 +91,38 @@ export class StraitClient {
91
91
  checkAppLinks(domain) {
92
92
  return this.call('checkAppLinks', 'GET', '/v1/tools/app-links', { query: { domain }, auth: false });
93
93
  }
94
+ getUsage() {
95
+ return this.call('getUsage', 'GET', '/v1/usage');
96
+ }
97
+ /** Referral report (preview): the engine answers 404 until referrals are switched on. */
98
+ getReferrals(q = {}) {
99
+ return this.call('getReferrals', 'GET', '/v1/referrals', { query: q });
100
+ }
101
+ /** The workspace's webhooks, from the export (configuration only: the engine never exports signing secrets). */
102
+ listWebhooks() {
103
+ return this.call('exportData', 'GET', '/v1/export', { query: { part: 'webhooks' } });
104
+ }
105
+ listWebhookDeliveries(webhookId, opts = {}) {
106
+ return this.call('listWebhookDeliveries', 'GET', `/v1/webhooks/${encodeURIComponent(webhookId)}/deliveries`, { query: { limit: opts.limit, cursor: opts.cursor } });
107
+ }
108
+ testWebhook(webhookId) {
109
+ return this.call('testWebhook', 'POST', `/v1/webhooks/${encodeURIComponent(webhookId)}/test`);
110
+ }
111
+ redeliverWebhookDelivery(deliveryId) {
112
+ return this.call('redeliverWebhookDelivery', 'POST', `/v1/webhook-deliveries/${encodeURIComponent(deliveryId)}/redeliver`);
113
+ }
114
+ /** DELETE /v1/links/{slug}: 204 deactivated; 404 (empty body) = no active link with that slug. */
115
+ async deactivateLink(slug) {
116
+ try {
117
+ await this.call('deactivateLink', 'DELETE', `/v1/links/${encodeURIComponent(slug)}`);
118
+ }
119
+ catch (err) {
120
+ if (err instanceof StraitApiError && err.status === 404 && !err.body) {
121
+ throw new StraitApiError(404, { error: `no active link with slug "${slug}" in this workspace` }, 'deactivateLink');
122
+ }
123
+ throw err;
124
+ }
125
+ }
94
126
  /** Live Tap Trace of the website demo (204 = no tap yet → null). */
95
127
  getTapTrace(token) {
96
128
  return this.call('getTapTrace', 'GET', `/v1/trace/${encodeURIComponent(token)}`, { auth: false }).then((r) => r ?? null);
package/dist/server.js CHANGED
@@ -6,7 +6,10 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
6
6
  import { StraitApiError } from './client.js';
7
7
  import { SERVER_INSTRUCTIONS, TOOLS } from './tools.js';
8
8
  export const SERVER_NAME = 'strait';
9
- export const SERVER_VERSION = '0.1.0';
9
+ export const SERVER_VERSION = '0.2.0';
10
+ /** Anything shaped like a secret key (st_live_… / st_test_…) never leaves the server in a tool result. */
11
+ const SECRET_KEY_SHAPE = /(?<![A-Za-z0-9])st_(?:live|test)_[A-Za-z0-9_-]+/g;
12
+ export const redactSecrets = (text) => text.replace(SECRET_KEY_SHAPE, 'st_…[redacted]');
10
13
  /** Turns a tool's return value (or error) into an MCP tool result. Errors are results, not protocol errors. */
11
14
  export async function runTool(t, client, args) {
12
15
  try {
@@ -17,7 +20,7 @@ export async function runTool(t, client, args) {
17
20
  };
18
21
  }
19
22
  const out = await t.run(client, args);
20
- return { content: [{ type: 'text', text: JSON.stringify(out ?? { ok: true }, null, 2) }] };
23
+ return { content: [{ type: 'text', text: redactSecrets(JSON.stringify(out ?? { ok: true }, null, 2)) }] };
21
24
  }
22
25
  catch (err) {
23
26
  const text = err instanceof StraitApiError
@@ -25,7 +28,7 @@ export async function runTool(t, client, args) {
25
28
  : err instanceof Error
26
29
  ? err.message
27
30
  : String(err);
28
- return { isError: true, content: [{ type: 'text', text }] };
31
+ return { isError: true, content: [{ type: 'text', text: redactSecrets(text) }] };
29
32
  }
30
33
  }
31
34
  export function createStraitServer(client, tools = TOOLS) {
package/dist/tools.js CHANGED
@@ -3,11 +3,13 @@
3
3
  * the stdio server, the remote Worker and the tests all register the same list.
4
4
  */
5
5
  import { z } from 'zod';
6
+ import { StraitApiError } from './client.js';
6
7
  import { searchDocs } from './docs.js';
7
8
  const tool = (t) => t;
8
9
  // ── shared input pieces ─────────────────────────────────────────────────────
9
10
  const slug = z.string().regex(/^[a-zA-Z0-9_-]{1,64}$/, 'letters, digits, - and _ (1–64)').describe("The link's slug: the part after the link domain, e.g. `summer` in https://hilltop.strait.link/summer");
10
11
  const httpsUrl = z.string().url().refine((u) => u.startsWith('https://'), 'must be an https URL');
12
+ const uuid = z.string().uuid();
11
13
  const when = z.union([z.string(), z.number()]).describe('ISO date-time (2026-12-31T23:59:59Z) or epoch ms');
12
14
  const newLinkShape = {
13
15
  slug,
@@ -61,6 +63,18 @@ function compactReport(report) {
61
63
  }
62
64
  return r;
63
65
  }
66
+ /** "zapier.com" from a receiver URL: the path, and on some services (Pipedream, ngrok) the subdomain, can be a secret. */
67
+ function hostOf(url) {
68
+ try {
69
+ const { hostname } = new URL(url);
70
+ if (/^[\d.]+$/.test(hostname) || hostname.startsWith('['))
71
+ return hostname;
72
+ return hostname.split('.').slice(-2).join('.');
73
+ }
74
+ catch {
75
+ return '(invalid URL)';
76
+ }
77
+ }
64
78
  const UPDATE_FIELDS = ['longUrl', 'title', 'tags', 'expiresAt', 'expiredAction', 'expiredUrl'];
65
79
  export const TOOLS = [
66
80
  tool({
@@ -208,6 +222,130 @@ export const TOOLS = [
208
222
  return { link: url, simulated: true, rows };
209
223
  },
210
224
  }),
225
+ tool({
226
+ name: 'get_usage',
227
+ description: "Read-only. This calendar month's usage (UTC) against the workspace's plan: billed taps used and included, new links " +
228
+ 'created and included (null = no limit), level (ok, warn from 80 % of included taps, over from 100 %) and new_links_paused. ' +
229
+ 'Going over never stops a redirect; new_links_paused means create_link answers 402 until an upgrade or the month ends.',
230
+ inputSchema: {},
231
+ annotations: { title: 'Read this month\'s usage', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
232
+ needsKey: true,
233
+ run: (c) => c.getUsage(),
234
+ }),
235
+ tool({
236
+ name: 'get_referrals',
237
+ description: 'Read-only. The referral report (a preview): for each referral code in a date range, the taps that carried it and the ' +
238
+ 'referred installs, most conversions first. Days are UTC; at most 92 days per call (default: the last 30 days). ' +
239
+ 'If referrals are not switched on for the workspace yet, says so instead of failing.',
240
+ inputSchema: {
241
+ from: z.string().optional().describe('Inclusive start: 2026-10-01, an ISO date-time or epoch ms'),
242
+ to: z.string().optional().describe('Exclusive end, same formats; default now'),
243
+ },
244
+ annotations: { title: 'Read the referral report', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
245
+ needsKey: true,
246
+ run: async (c, a) => {
247
+ try {
248
+ return await c.getReferrals(a);
249
+ }
250
+ catch (err) {
251
+ // The engine's unmounted-route answer, not any 404 (e.g. a wrong STRAIT_API_URL serving HTML).
252
+ if (err instanceof StraitApiError && err.status === 404 && err.body?.error === 'not found') {
253
+ return { available: false, note: 'The referral report is a preview and is not switched on for the API yet. Referral codes still travel on links; the report appears here once it is switched on.' };
254
+ }
255
+ throw err;
256
+ }
257
+ },
258
+ }),
259
+ tool({
260
+ name: 'list_webhook_deliveries',
261
+ description: "Read-only. A webhook's delivery history, newest first (kept 30 days): each event sent, its status (pending = waiting " +
262
+ 'for a retry, delivered, failed = gave up after 6 attempts), attempts, last HTTP status or error and the next retry time. ' +
263
+ 'A permanent error (e.g. unsafe_target) fails at once. Payloads are never returned. Without webhookId it lists the ' +
264
+ 'workspace\'s webhooks (id, receiver site, events, active) ' +
265
+ 'so you can pick one. Use it when a webhook seems to have stopped.',
266
+ inputSchema: {
267
+ webhookId: uuid.optional().describe('The webhook id; omit to list the webhooks first'),
268
+ limit: z.number().int().min(1).max(100).optional().describe('Page size, default 20'),
269
+ cursor: z.string().optional().describe('nextCursor from the previous page'),
270
+ },
271
+ annotations: { title: 'List webhook deliveries', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
272
+ needsKey: true,
273
+ run: async (c, a) => {
274
+ if (a.webhookId)
275
+ return c.listWebhookDeliveries(a.webhookId, a);
276
+ const { webhooks } = await c.listWebhooks();
277
+ return {
278
+ // Receiver URLs are often capability URLs (Zapier, Slack): only the host leaves the engine's answer.
279
+ webhooks: webhooks.map((w) => ({ id: w.id, site: hostOf(w.url), events: w.events, active: w.active, createdAt: w.createdAt })),
280
+ next: webhooks.length
281
+ ? 'Call list_webhook_deliveries again with webhookId set to one of these ids.'
282
+ : 'This workspace has no webhooks. Add one in Dashboard → Settings → Webhooks.',
283
+ };
284
+ },
285
+ }),
286
+ tool({
287
+ name: 'test_webhook',
288
+ description: 'Write. Sends one test event (event: ping) to a webhook right now, even if it is paused, and returns whether the receiver ' +
289
+ 'accepted it, its HTTP status and the time taken (never its response body). The ping is not stored or retried and ' +
290
+ 'changes nothing in Strait. Get the id from list_webhook_deliveries without webhookId.',
291
+ inputSchema: { webhookId: uuid.describe('The webhook id') },
292
+ annotations: { title: 'Send a test webhook', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
293
+ needsKey: true,
294
+ run: (c, a) => c.testWebhook(a.webhookId),
295
+ }),
296
+ tool({
297
+ name: 'redeliver_webhook_delivery',
298
+ description: "Write. Sends one past webhook delivery again, right away, to the webhook's current URL, with the same X-Strait-Delivery id " +
299
+ '(receivers that de-duplicate on it are safe); its retry schedule starts over. For failed deliveries. This re-sends a real ' +
300
+ 'event into the receiving system, so it needs confirm: true; without it nothing is sent. Refused (409) when already ' +
301
+ 'delivered or being sent right now. A paused webhook must be turned back on first (Dashboard → Settings → Webhooks), ' +
302
+ 'otherwise the redelivery fails at once.',
303
+ inputSchema: {
304
+ deliveryId: uuid.describe('The delivery id, from list_webhook_deliveries'),
305
+ confirm: z.boolean().optional().describe('true to send it; omit or false to preview'),
306
+ },
307
+ annotations: { title: 'Redeliver a webhook delivery', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
308
+ needsKey: true,
309
+ run: async (c, a) => {
310
+ if (a.confirm !== true) {
311
+ return {
312
+ preview: true,
313
+ applied: false,
314
+ deliveryId: a.deliveryId,
315
+ next: 'Nothing has been sent. This re-sends a real event to the webhook receiver. If the user agrees, call redeliver_webhook_delivery again with confirm: true.',
316
+ };
317
+ }
318
+ return { applied: true, delivery: await c.redeliverWebhookDelivery(a.deliveryId) };
319
+ },
320
+ }),
321
+ tool({
322
+ name: 'deactivate_link',
323
+ description: 'Write. Turns a link off: afterwards every tap on it gets the "link not found" page instead of the app, store or website. ' +
324
+ 'Its history stays in analytics. The API has no way to turn a link back on yet, so this needs confirm: true; without it ' +
325
+ 'nothing changes and you get a preview of the link to show the user first.',
326
+ inputSchema: {
327
+ slug,
328
+ confirm: z.boolean().optional().describe('true to deactivate; omit or false to preview'),
329
+ },
330
+ annotations: { title: 'Deactivate a link', readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
331
+ needsKey: true,
332
+ run: async (c, a) => {
333
+ if (a.confirm !== true) {
334
+ const current = await c.getLink(a.slug);
335
+ return {
336
+ preview: true,
337
+ applied: false,
338
+ slug: a.slug,
339
+ shortUrl: current.shortUrl,
340
+ longUrl: current.longUrl,
341
+ title: current.title ?? null,
342
+ next: 'Nothing has changed yet. After deactivation taps get the "link not found" page, and the API cannot turn it back on. If the user agrees, call deactivate_link again with confirm: true.',
343
+ };
344
+ }
345
+ await c.deactivateLink(a.slug);
346
+ return { applied: true, slug: a.slug, deactivated: true };
347
+ },
348
+ }),
211
349
  tool({
212
350
  name: 'search_docs',
213
351
  description: 'Search the Strait documentation (SDK setup for React Native / Expo, Flutter, Android, iOS, Unity, web and Node; App Links and ' +
@@ -238,7 +376,8 @@ export const SERVER_INSTRUCTIONS = [
238
376
  'Strait makes links open the right screen in a mobile app (deep links), including after an install (deferred deep links), and reports what happened to each tap.',
239
377
  'Keys: the secret key (st_live_…) is for servers and this MCP server only. Never put it in app or website code. Apps and websites use the publishable key (st_pub_live_…) with the Strait SDKs.',
240
378
  'Share a link by its shortUrl. Prefer batch_create_links over many create_link calls.',
241
- 'update_link changes where live links go: call it without confirm first, show the user the preview, and only then call it with confirm: true.',
379
+ 'update_link, deactivate_link and redeliver_webhook_delivery change live behaviour: call them without confirm first, show the user the preview, and only then call them with confirm: true.',
380
+ 'When a webhook seems to have stopped: list_webhook_deliveries (without webhookId to find the id), then test_webhook, then redeliver_webhook_delivery for failed deliveries.',
242
381
  'When a link opens the browser instead of the app, run check_app_links on the link domain and the app domain, then trace_tap on the link.',
243
382
  'iPhone install matching (deferred deep linking on iOS) is in beta; Android uses the Play Install Referrer. On iPhone the workspace picks the method (device matching, paste handoff, both or neither) in Dashboard → Settings → iPhone installs, read by the SDK at runtime; device matching is off for new workspaces. Use search_docs before answering setup questions.',
244
383
  ].join('\n');
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@straitlink/mcp",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "mcpName": "in.straitlink/mcp",
5
- "description": "Strait MCP server: create and inspect deep links, read analytics, check App Links / Universal Links files and search the docs from AI tools.",
5
+ "description": "Strait MCP server: create, inspect and deactivate deep links, read analytics, usage and referrals, check and redeliver webhooks, check App Links / Universal Links files and search the docs from AI tools.",
6
6
  "keywords": [
7
7
  "mcp",
8
8
  "model-context-protocol",