@straitlink/mcp 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
@@ -38,16 +39,25 @@ workspaces. The tools report iPhone results the same way, but treat iPhone insta
38
39
  | `create_link` | Create one link and get its `shortUrl` | secret |
39
40
  | `batch_create_links` | Create up to 500 links in one call (safe to re-run: existing slugs are per-item 409s) | secret |
40
41
  | `get_link` | Read one link by slug | secret |
41
- | `list_links` | List links, one page at a time | secret |
42
- | `update_link` | Change destination, title, tags or expiry. **Asks for `confirm: true`**: without it you get a preview and nothing changes | secret |
42
+ | `list_links` | List links, one page at a time. `status: "paused"` lists turned-off links (0.2.1) | secret |
43
+ | `update_link` | Change destination, title, tags or expiry, or `isActive` on its own (false pauses, true turns a paused link back on; 0.2.1). **Asks for `confirm: true`**: without it you get a preview and nothing changes | secret |
43
44
  | `get_analytics` | Taps, app opens, installs, conversions and revenue, by day / channel / link / platform (`GET /v1/analytics`). iPhone installs come from beta install matching | secret |
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`**; turn it back on with `update_link` `isActive: true` | 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
@@ -77,7 +77,7 @@ export class StraitClient {
77
77
  return this.call('getLink', 'GET', `/v1/links/${encodeURIComponent(slug)}`);
78
78
  }
79
79
  listLinks(opts = {}) {
80
- return this.call('listLinks', 'GET', '/v1/links', { query: { limit: opts.limit ?? 50, cursor: opts.cursor } });
80
+ return this.call('listLinks', 'GET', '/v1/links', { query: { limit: opts.limit ?? 50, cursor: opts.cursor, status: opts.status } });
81
81
  }
82
82
  updateLink(slug, patch) {
83
83
  return this.call('updateLink', 'PATCH', `/v1/links/${encodeURIComponent(slug)}`, { body: patch });
@@ -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.1';
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,7 +63,55 @@ 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'];
79
+ /** Paused pages a turn-back-on preview reads to find the link (100 each). */
80
+ const PAUSED_PREVIEW_PAGES = 5;
81
+ /** update_link with isActive (engine ADR 0041: PATCH { isActive } sent alone). */
82
+ async function setActive(c, slug, isActive, confirm, otherFields) {
83
+ if (otherFields)
84
+ throw new Error('send isActive on its own, then other fields in a second update_link call');
85
+ if (confirm)
86
+ return { applied: true, link: await c.updateLink(slug, { isActive }) };
87
+ const next = `Nothing has changed yet. Show this to the user; if they agree, call update_link again with slug "${slug}", isActive: ${isActive} and confirm: true.`;
88
+ const live = await c.getLink(slug).catch((err) => {
89
+ if (err instanceof StraitApiError && err.status === 404)
90
+ return null;
91
+ throw err;
92
+ });
93
+ if (!isActive) {
94
+ if (!live)
95
+ throw new StraitApiError(404, { error: `no active link with slug "${slug}" in this workspace` }, 'updateLink');
96
+ return { preview: true, applied: false, slug, shortUrl: live.shortUrl, current: { isActive: true, longUrl: live.longUrl }, proposed: { isActive: false }, next };
97
+ }
98
+ if (live)
99
+ return { preview: true, applied: false, alreadyOn: true, slug, shortUrl: live.shortUrl, longUrl: live.longUrl, next: 'This link is already on. Nothing to do.' };
100
+ let cursor;
101
+ for (let page = 0; page < PAUSED_PREVIEW_PAGES; page++) {
102
+ const r = await c.listLinks({ status: 'paused', limit: 100, cursor });
103
+ const hit = r.links.find((l) => l.slug === slug);
104
+ if (hit)
105
+ return { preview: true, applied: false, slug, shortUrl: hit.shortUrl, current: { isActive: false, longUrl: hit.longUrl, title: hit.title ?? null }, proposed: { isActive: true }, next };
106
+ if (!r.nextCursor)
107
+ break;
108
+ cursor = r.nextCursor;
109
+ }
110
+ return {
111
+ preview: true, applied: false, found: false, slug,
112
+ next: `No paused link "${slug}" in the newest ${PAUSED_PREVIEW_PAGES * 100} paused links (list_links status: paused shows them). Confirming answers 404 if there is none.`,
113
+ };
114
+ }
65
115
  export const TOOLS = [
66
116
  tool({
67
117
  name: 'create_link',
@@ -92,10 +142,12 @@ export const TOOLS = [
92
142
  }),
93
143
  tool({
94
144
  name: 'list_links',
95
- description: 'List active links, newest first, one page at a time. Pass nextCursor back as cursor until it is null.',
145
+ description: 'List links, newest first, one page at a time. Pass nextCursor back as cursor until it is null. ' +
146
+ 'status: active (default) or paused (turned off, and can be turned back on with update_link isActive: true; links disabled for abuse are not listed).',
96
147
  inputSchema: {
97
148
  limit: z.number().int().min(1).max(100).optional().describe('Page size, default 50'),
98
149
  cursor: z.string().optional().describe('nextCursor from the previous page'),
150
+ status: z.enum(['active', 'paused']).optional().describe('active (default) or paused'),
99
151
  },
100
152
  annotations: { title: 'List links', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
101
153
  needsKey: true,
@@ -103,9 +155,11 @@ export const TOOLS = [
103
155
  }),
104
156
  tool({
105
157
  name: 'update_link',
106
- description: 'Change a live link: its destination, title, tags or expiry. People who tap the link afterwards go to the new destination, ' +
158
+ description: 'Change a link: its destination, title, tags or expiry, or turn it off or back on with isActive. People who tap the link afterwards get the change, ' +
107
159
  'so this needs confirm: true. Without it nothing changes: you get a preview of the current and proposed values to show the user first. ' +
108
- 'null clears expiresAt / expiredAction / expiredUrl.',
160
+ 'null clears expiresAt / expiredAction / expiredUrl. isActive is sent on its own: false pauses the link (taps get the "link not found" page), ' +
161
+ 'true turns a paused link back on (taps follow within about 30 seconds). A link disabled for breaking the acceptable use policy cannot be turned back on, ' +
162
+ 'and if another active link now uses the slug, pause or rename that one first.',
109
163
  inputSchema: {
110
164
  slug,
111
165
  longUrl: httpsUrl.optional(),
@@ -114,11 +168,14 @@ export const TOOLS = [
114
168
  expiresAt: when.nullable().optional(),
115
169
  expiredAction: z.enum(['page', 'store', 'url']).nullable().optional(),
116
170
  expiredUrl: httpsUrl.nullable().optional(),
171
+ isActive: z.boolean().optional().describe('Alone: false pauses the link, true turns a paused link back on'),
117
172
  confirm: z.boolean().optional().describe('true to apply the change; omit or false to preview it'),
118
173
  },
119
174
  annotations: { title: 'Update a link', readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
120
175
  needsKey: true,
121
176
  run: async (c, a) => {
177
+ if (a.isActive !== undefined)
178
+ return setActive(c, a.slug, a.isActive, a.confirm === true, UPDATE_FIELDS.some((f) => a[f] !== undefined));
122
179
  const patch = {};
123
180
  for (const f of UPDATE_FIELDS)
124
181
  if (a[f] !== undefined)
@@ -208,6 +265,130 @@ export const TOOLS = [
208
265
  return { link: url, simulated: true, rows };
209
266
  },
210
267
  }),
268
+ tool({
269
+ name: 'get_usage',
270
+ description: "Read-only. This calendar month's usage (UTC) against the workspace's plan: billed taps used and included, new links " +
271
+ 'created and included (null = no limit), level (ok, warn from 80 % of included taps, over from 100 %) and new_links_paused. ' +
272
+ 'Going over never stops a redirect; new_links_paused means create_link answers 402 until an upgrade or the month ends.',
273
+ inputSchema: {},
274
+ annotations: { title: 'Read this month\'s usage', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
275
+ needsKey: true,
276
+ run: (c) => c.getUsage(),
277
+ }),
278
+ tool({
279
+ name: 'get_referrals',
280
+ description: 'Read-only. The referral report (a preview): for each referral code in a date range, the taps that carried it and the ' +
281
+ 'referred installs, most conversions first. Days are UTC; at most 92 days per call (default: the last 30 days). ' +
282
+ 'If referrals are not switched on for the workspace yet, says so instead of failing.',
283
+ inputSchema: {
284
+ from: z.string().optional().describe('Inclusive start: 2026-10-01, an ISO date-time or epoch ms'),
285
+ to: z.string().optional().describe('Exclusive end, same formats; default now'),
286
+ },
287
+ annotations: { title: 'Read the referral report', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
288
+ needsKey: true,
289
+ run: async (c, a) => {
290
+ try {
291
+ return await c.getReferrals(a);
292
+ }
293
+ catch (err) {
294
+ // The engine's unmounted-route answer, not any 404 (e.g. a wrong STRAIT_API_URL serving HTML).
295
+ if (err instanceof StraitApiError && err.status === 404 && err.body?.error === 'not found') {
296
+ 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.' };
297
+ }
298
+ throw err;
299
+ }
300
+ },
301
+ }),
302
+ tool({
303
+ name: 'list_webhook_deliveries',
304
+ description: "Read-only. A webhook's delivery history, newest first (kept 30 days): each event sent, its status (pending = waiting " +
305
+ 'for a retry, delivered, failed = gave up after 6 attempts), attempts, last HTTP status or error and the next retry time. ' +
306
+ 'A permanent error (e.g. unsafe_target) fails at once. Payloads are never returned. Without webhookId it lists the ' +
307
+ 'workspace\'s webhooks (id, receiver site, events, active) ' +
308
+ 'so you can pick one. Use it when a webhook seems to have stopped.',
309
+ inputSchema: {
310
+ webhookId: uuid.optional().describe('The webhook id; omit to list the webhooks first'),
311
+ limit: z.number().int().min(1).max(100).optional().describe('Page size, default 20'),
312
+ cursor: z.string().optional().describe('nextCursor from the previous page'),
313
+ },
314
+ annotations: { title: 'List webhook deliveries', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
315
+ needsKey: true,
316
+ run: async (c, a) => {
317
+ if (a.webhookId)
318
+ return c.listWebhookDeliveries(a.webhookId, a);
319
+ const { webhooks } = await c.listWebhooks();
320
+ return {
321
+ // Receiver URLs are often capability URLs (Zapier, Slack): only the host leaves the engine's answer.
322
+ webhooks: webhooks.map((w) => ({ id: w.id, site: hostOf(w.url), events: w.events, active: w.active, createdAt: w.createdAt })),
323
+ next: webhooks.length
324
+ ? 'Call list_webhook_deliveries again with webhookId set to one of these ids.'
325
+ : 'This workspace has no webhooks. Add one in Dashboard → Settings → Webhooks.',
326
+ };
327
+ },
328
+ }),
329
+ tool({
330
+ name: 'test_webhook',
331
+ description: 'Write. Sends one test event (event: ping) to a webhook right now, even if it is paused, and returns whether the receiver ' +
332
+ 'accepted it, its HTTP status and the time taken (never its response body). The ping is not stored or retried and ' +
333
+ 'changes nothing in Strait. Get the id from list_webhook_deliveries without webhookId.',
334
+ inputSchema: { webhookId: uuid.describe('The webhook id') },
335
+ annotations: { title: 'Send a test webhook', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
336
+ needsKey: true,
337
+ run: (c, a) => c.testWebhook(a.webhookId),
338
+ }),
339
+ tool({
340
+ name: 'redeliver_webhook_delivery',
341
+ description: "Write. Sends one past webhook delivery again, right away, to the webhook's current URL, with the same X-Strait-Delivery id " +
342
+ '(receivers that de-duplicate on it are safe); its retry schedule starts over. For failed deliveries. This re-sends a real ' +
343
+ 'event into the receiving system, so it needs confirm: true; without it nothing is sent. Refused (409) when already ' +
344
+ 'delivered or being sent right now. A paused webhook must be turned back on first (Dashboard → Settings → Webhooks), ' +
345
+ 'otherwise the redelivery fails at once.',
346
+ inputSchema: {
347
+ deliveryId: uuid.describe('The delivery id, from list_webhook_deliveries'),
348
+ confirm: z.boolean().optional().describe('true to send it; omit or false to preview'),
349
+ },
350
+ annotations: { title: 'Redeliver a webhook delivery', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
351
+ needsKey: true,
352
+ run: async (c, a) => {
353
+ if (a.confirm !== true) {
354
+ return {
355
+ preview: true,
356
+ applied: false,
357
+ deliveryId: a.deliveryId,
358
+ 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.',
359
+ };
360
+ }
361
+ return { applied: true, delivery: await c.redeliverWebhookDelivery(a.deliveryId) };
362
+ },
363
+ }),
364
+ tool({
365
+ name: 'deactivate_link',
366
+ description: 'Write. Turns a link off: afterwards every tap on it gets the "link not found" page instead of the app, store or website. ' +
367
+ 'Its history stays in analytics, and it can be turned back on with update_link with isActive: true. This needs confirm: true; without it ' +
368
+ 'nothing changes and you get a preview of the link to show the user first.',
369
+ inputSchema: {
370
+ slug,
371
+ confirm: z.boolean().optional().describe('true to deactivate; omit or false to preview'),
372
+ },
373
+ annotations: { title: 'Deactivate a link', readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
374
+ needsKey: true,
375
+ run: async (c, a) => {
376
+ if (a.confirm !== true) {
377
+ const current = await c.getLink(a.slug);
378
+ return {
379
+ preview: true,
380
+ applied: false,
381
+ slug: a.slug,
382
+ shortUrl: current.shortUrl,
383
+ longUrl: current.longUrl,
384
+ title: current.title ?? null,
385
+ next: 'Nothing has changed yet. After deactivation taps get the "link not found" page until it is turned back on (update_link isActive: true). If the user agrees, call deactivate_link again with confirm: true.',
386
+ };
387
+ }
388
+ await c.deactivateLink(a.slug);
389
+ return { applied: true, slug: a.slug, deactivated: true };
390
+ },
391
+ }),
211
392
  tool({
212
393
  name: 'search_docs',
213
394
  description: 'Search the Strait documentation (SDK setup for React Native / Expo, Flutter, Android, iOS, Unity, web and Node; App Links and ' +
@@ -238,7 +419,9 @@ export const SERVER_INSTRUCTIONS = [
238
419
  '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
420
  '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
421
  '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.',
422
+ '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.',
423
+ 'To turn a paused link back on: list_links with status: paused to find it, then update_link with isActive: true (preview, then confirm: true).',
424
+ '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
425
  '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
426
  '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
427
  ].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.1",
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",
@@ -55,7 +55,7 @@
55
55
  "@types/node": "^22.20.5",
56
56
  "tsx": "^4.23.15",
57
57
  "typescript": "5.9",
58
- "vitest": "^4.1.8"
58
+ "vitest": "^4.1.12"
59
59
  },
60
60
  "publishConfig": {
61
61
  "access": "public",