@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 +3 -3
- package/dist/client.js +1 -1
- package/dist/server.js +1 -1
- package/dist/tools.js +49 -5
- package/package.json +2 -2
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`**;
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
58
|
+
"vitest": "^4.1.12"
|
|
59
59
|
},
|
|
60
60
|
"publishConfig": {
|
|
61
61
|
"access": "public",
|