@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 +38 -11
- package/dist/client.js +33 -1
- package/dist/server.js +6 -3
- package/dist/tools.js +187 -4
- package/package.json +3 -3
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,
|
|
19
|
-
analytics, check App Links / Universal Links files, see where taps on a link go,
|
|
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
|
|
22
|
-
`
|
|
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`, …)
|
|
50
|
-
|
|
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
|
|
134
|
-
`
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"mcpName": "in.straitlink/mcp",
|
|
5
|
-
"description": "Strait MCP server: create and
|
|
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.
|
|
58
|
+
"vitest": "^4.1.12"
|
|
59
59
|
},
|
|
60
60
|
"publishConfig": {
|
|
61
61
|
"access": "public",
|