@straitlink/mcp 0.2.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
@@ -39,8 +39,8 @@ workspaces. The tools report iPhone results the same way, but treat iPhone insta
39
39
  | `create_link` | Create one link and get its `shortUrl` | secret |
40
40
  | `batch_create_links` | Create up to 500 links in one call (safe to re-run: existing slugs are per-item 409s) | secret |
41
41
  | `get_link` | Read one link by slug | secret |
42
- | `list_links` | List links, one page at a time | secret |
43
- | `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 |
44
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 |
45
45
  | `check_app_links` | Check a domain's `assetlinks.json` and `apple-app-site-association`, with a fix for each problem | none |
46
46
  | `simulate_tap` | Dry-run one tap from one device or app (nothing recorded or billed) | secret |
@@ -50,7 +50,7 @@ workspaces. The tools report iPhone results the same way, but treat iPhone insta
50
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
51
  | `test_webhook` | Send one `ping` to a webhook now and see the receiver's status and time (not stored, not retried) | secret |
52
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 |
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 |
54
54
  | `search_docs` | Search the Strait docs offline (bundled index) | none |
55
55
 
56
56
  Every tool carries MCP annotations (`readOnlyHint`, `destructiveHint`, …) and its description starts with
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 });
package/dist/server.js CHANGED
@@ -6,7 +6,7 @@ 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.2.0';
9
+ export const SERVER_VERSION = '0.2.1';
10
10
  /** Anything shaped like a secret key (st_live_… / st_test_…) never leaves the server in a tool result. */
11
11
  const SECRET_KEY_SHAPE = /(?<![A-Za-z0-9])st_(?:live|test)_[A-Za-z0-9_-]+/g;
12
12
  export const redactSecrets = (text) => text.replace(SECRET_KEY_SHAPE, 'st_…[redacted]');
package/dist/tools.js CHANGED
@@ -76,6 +76,42 @@ function hostOf(url) {
76
76
  }
77
77
  }
78
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
+ }
79
115
  export const TOOLS = [
80
116
  tool({
81
117
  name: 'create_link',
@@ -106,10 +142,12 @@ export const TOOLS = [
106
142
  }),
107
143
  tool({
108
144
  name: 'list_links',
109
- 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).',
110
147
  inputSchema: {
111
148
  limit: z.number().int().min(1).max(100).optional().describe('Page size, default 50'),
112
149
  cursor: z.string().optional().describe('nextCursor from the previous page'),
150
+ status: z.enum(['active', 'paused']).optional().describe('active (default) or paused'),
113
151
  },
114
152
  annotations: { title: 'List links', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
115
153
  needsKey: true,
@@ -117,9 +155,11 @@ export const TOOLS = [
117
155
  }),
118
156
  tool({
119
157
  name: 'update_link',
120
- 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, ' +
121
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. ' +
122
- '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.',
123
163
  inputSchema: {
124
164
  slug,
125
165
  longUrl: httpsUrl.optional(),
@@ -128,11 +168,14 @@ export const TOOLS = [
128
168
  expiresAt: when.nullable().optional(),
129
169
  expiredAction: z.enum(['page', 'store', 'url']).nullable().optional(),
130
170
  expiredUrl: httpsUrl.nullable().optional(),
171
+ isActive: z.boolean().optional().describe('Alone: false pauses the link, true turns a paused link back on'),
131
172
  confirm: z.boolean().optional().describe('true to apply the change; omit or false to preview it'),
132
173
  },
133
174
  annotations: { title: 'Update a link', readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
134
175
  needsKey: true,
135
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));
136
179
  const patch = {};
137
180
  for (const f of UPDATE_FIELDS)
138
181
  if (a[f] !== undefined)
@@ -321,7 +364,7 @@ export const TOOLS = [
321
364
  tool({
322
365
  name: 'deactivate_link',
323
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. ' +
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 ' +
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 ' +
325
368
  'nothing changes and you get a preview of the link to show the user first.',
326
369
  inputSchema: {
327
370
  slug,
@@ -339,7 +382,7 @@ export const TOOLS = [
339
382
  shortUrl: current.shortUrl,
340
383
  longUrl: current.longUrl,
341
384
  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.',
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.',
343
386
  };
344
387
  }
345
388
  await c.deactivateLink(a.slug);
@@ -377,6 +420,7 @@ export const SERVER_INSTRUCTIONS = [
377
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.',
378
421
  'Share a link by its shortUrl. Prefer batch_create_links over many create_link calls.',
379
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).',
380
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.',
381
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.',
382
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.',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@straitlink/mcp",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "mcpName": "in.straitlink/mcp",
5
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": [
@@ -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",