@straitlink/mcp 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +224 -2
- package/dist/client.js +98 -0
- package/dist/docs-index.json +2960 -0
- package/dist/docs.js +60 -0
- package/dist/server.js +37 -0
- package/dist/stdio.js +28 -0
- package/dist/tools.js +244 -0
- package/package.json +62 -4
package/dist/docs.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* search_docs: keyword search over the bundled Strait docs (src/docs-index.json,
|
|
3
|
+
* built from the website's docs by scripts/build-docs-index.mjs). Offline: no
|
|
4
|
+
* network call, so it works in every client and never leaks the query.
|
|
5
|
+
*/
|
|
6
|
+
import index from './docs-index.json' with { type: 'json' };
|
|
7
|
+
const SECTIONS = index.sections;
|
|
8
|
+
const STOP = new Set(['a', 'an', 'and', 'are', 'as', 'at', 'be', 'by', 'can', 'do', 'does', 'for', 'from', 'how', 'i', 'in', 'is', 'it', 'my', 'of', 'on', 'or', 'the', 'to', 'what', 'when', 'why', 'with', 'you', 'your', 'strait']);
|
|
9
|
+
export function tokens(text) {
|
|
10
|
+
return text
|
|
11
|
+
.toLowerCase()
|
|
12
|
+
.replace(/[`*_#>\[\]()|]/g, ' ')
|
|
13
|
+
.split(/[^a-z0-9.+-]+/)
|
|
14
|
+
.map((t) => t.replace(/^[.+-]+|[.+-]+$/g, ''))
|
|
15
|
+
.filter((t) => t.length > 1 && !STOP.has(t));
|
|
16
|
+
}
|
|
17
|
+
let built = null;
|
|
18
|
+
function build(sections) {
|
|
19
|
+
const docs = sections.map((s) => {
|
|
20
|
+
const words = tokens(s.markdown);
|
|
21
|
+
const tf = new Map();
|
|
22
|
+
for (const w of words)
|
|
23
|
+
tf.set(w, (tf.get(w) ?? 0) + 1);
|
|
24
|
+
return { s, tf, head: new Set(tokens(`${s.page} ${s.section ?? ''}`)), len: words.length };
|
|
25
|
+
});
|
|
26
|
+
const df = new Map();
|
|
27
|
+
for (const d of docs)
|
|
28
|
+
for (const w of new Set([...d.tf.keys(), ...d.head]))
|
|
29
|
+
df.set(w, (df.get(w) ?? 0) + 1);
|
|
30
|
+
const avg = docs.reduce((n, d) => n + d.len, 0) / Math.max(1, docs.length);
|
|
31
|
+
return { docs, df, avg };
|
|
32
|
+
}
|
|
33
|
+
/** BM25 over section text, plus a boost when a query word is in the page title or section heading. */
|
|
34
|
+
export function searchDocs(query, limit = 5, sections = SECTIONS) {
|
|
35
|
+
const { docs, df, avg } = sections === SECTIONS ? (built ??= build(SECTIONS)) : build(sections);
|
|
36
|
+
const q = [...new Set(tokens(query))];
|
|
37
|
+
if (!q.length)
|
|
38
|
+
return [];
|
|
39
|
+
const N = docs.length;
|
|
40
|
+
const k1 = 1.2;
|
|
41
|
+
const b = 0.75;
|
|
42
|
+
const hits = [];
|
|
43
|
+
for (const d of docs) {
|
|
44
|
+
let score = 0;
|
|
45
|
+
for (const w of q) {
|
|
46
|
+
const n = df.get(w) ?? 0;
|
|
47
|
+
if (!n)
|
|
48
|
+
continue;
|
|
49
|
+
const idf = Math.log(1 + (N - n + 0.5) / (n + 0.5));
|
|
50
|
+
const f = d.tf.get(w) ?? 0;
|
|
51
|
+
score += idf * ((f * (k1 + 1)) / (f + k1 * (1 - b + (b * d.len) / avg)));
|
|
52
|
+
if (d.head.has(w))
|
|
53
|
+
score += idf * 1.5;
|
|
54
|
+
}
|
|
55
|
+
if (score > 0)
|
|
56
|
+
hits.push({ ...d.s, score: Math.round(score * 100) / 100 });
|
|
57
|
+
}
|
|
58
|
+
return hits.sort((x, y) => y.score - x.score).slice(0, Math.max(1, Math.min(limit, 20)));
|
|
59
|
+
}
|
|
60
|
+
export const DOCS_SECTION_COUNT = SECTIONS.length;
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* createStraitServer(): an McpServer with every Strait tool registered. Used by
|
|
3
|
+
* the stdio entry (src/stdio.ts), the remote Worker (src/worker.ts) and the tests.
|
|
4
|
+
*/
|
|
5
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
6
|
+
import { StraitApiError } from './client.js';
|
|
7
|
+
import { SERVER_INSTRUCTIONS, TOOLS } from './tools.js';
|
|
8
|
+
export const SERVER_NAME = 'strait';
|
|
9
|
+
export const SERVER_VERSION = '0.1.0';
|
|
10
|
+
/** Turns a tool's return value (or error) into an MCP tool result. Errors are results, not protocol errors. */
|
|
11
|
+
export async function runTool(t, client, args) {
|
|
12
|
+
try {
|
|
13
|
+
if (t.needsKey && !client.hasSecretKey) {
|
|
14
|
+
return {
|
|
15
|
+
isError: true,
|
|
16
|
+
content: [{ type: 'text', text: `${t.name} needs a Strait secret key. Set STRAIT_SECRET_KEY (st_live_…, from Dashboard → Settings → Secret keys) in this MCP server's environment.` }],
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
const out = await t.run(client, args);
|
|
20
|
+
return { content: [{ type: 'text', text: JSON.stringify(out ?? { ok: true }, null, 2) }] };
|
|
21
|
+
}
|
|
22
|
+
catch (err) {
|
|
23
|
+
const text = err instanceof StraitApiError
|
|
24
|
+
? `${err.message}${err.body && typeof err.body === 'object' ? `\n${JSON.stringify(err.body)}` : ''}`
|
|
25
|
+
: err instanceof Error
|
|
26
|
+
? err.message
|
|
27
|
+
: String(err);
|
|
28
|
+
return { isError: true, content: [{ type: 'text', text }] };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export function createStraitServer(client, tools = TOOLS) {
|
|
32
|
+
const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION }, { instructions: SERVER_INSTRUCTIONS });
|
|
33
|
+
for (const t of tools) {
|
|
34
|
+
server.registerTool(t.name, { title: t.annotations.title, description: t.description, inputSchema: t.inputSchema, annotations: t.annotations }, (args) => runTool(t, client, args));
|
|
35
|
+
}
|
|
36
|
+
return server;
|
|
37
|
+
}
|
package/dist/stdio.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Local MCP server over stdio: npx @straitlink/mcp (or node dist/stdio.js)
|
|
4
|
+
* Env:
|
|
5
|
+
* STRAIT_SECRET_KEY a secret key (st_live_…) from Dashboard → Settings → Secret keys. Without it only
|
|
6
|
+
* check_app_links, search_docs and demo trace_tap work.
|
|
7
|
+
* STRAIT_API_URL engine URL, default https://strait.link (e.g. http://localhost:3000 for a local engine)
|
|
8
|
+
*/
|
|
9
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
10
|
+
import { StraitClient, isSecretKey } from './client.js';
|
|
11
|
+
import { createStraitServer, SERVER_VERSION } from './server.js';
|
|
12
|
+
const secretKey = process.env.STRAIT_SECRET_KEY?.trim() || undefined;
|
|
13
|
+
if (secretKey && !isSecretKey(secretKey)) {
|
|
14
|
+
// stdout is the protocol channel: diagnostics go to stderr.
|
|
15
|
+
console.error(secretKey.startsWith('st_pub_')
|
|
16
|
+
? 'strait-mcp: STRAIT_SECRET_KEY is a publishable key (st_pub_…). Use a secret key (st_live_…) from Dashboard → Settings → Secret keys.'
|
|
17
|
+
: 'strait-mcp: STRAIT_SECRET_KEY does not look like a Strait secret key (st_live_…).');
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
const client = new StraitClient({
|
|
21
|
+
apiUrl: process.env.STRAIT_API_URL?.trim() || undefined,
|
|
22
|
+
secretKey,
|
|
23
|
+
userAgent: `strait-mcp/${SERVER_VERSION} (stdio)`,
|
|
24
|
+
});
|
|
25
|
+
const server = createStraitServer(client);
|
|
26
|
+
await server.connect(new StdioServerTransport());
|
|
27
|
+
if (!secretKey)
|
|
28
|
+
console.error('strait-mcp: no STRAIT_SECRET_KEY set; link and analytics tools will ask for one.');
|
package/dist/tools.js
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Strait MCP tools. Each tool is plain data plus a run() over StraitClient, so
|
|
3
|
+
* the stdio server, the remote Worker and the tests all register the same list.
|
|
4
|
+
*/
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { searchDocs } from './docs.js';
|
|
7
|
+
const tool = (t) => t;
|
|
8
|
+
// ── shared input pieces ─────────────────────────────────────────────────────
|
|
9
|
+
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
|
+
const httpsUrl = z.string().url().refine((u) => u.startsWith('https://'), 'must be an https URL');
|
|
11
|
+
const when = z.union([z.string(), z.number()]).describe('ISO date-time (2026-12-31T23:59:59Z) or epoch ms');
|
|
12
|
+
const newLinkShape = {
|
|
13
|
+
slug,
|
|
14
|
+
longUrl: httpsUrl.describe('Where the link goes: an https URL on your app\'s domain. The app receives its path and query as the in-app route.'),
|
|
15
|
+
title: z.string().max(200).optional().describe('Social preview title'),
|
|
16
|
+
description: z.string().max(500).optional().describe('Social preview description'),
|
|
17
|
+
imageUrl: httpsUrl.optional().describe('Social preview image (https)'),
|
|
18
|
+
campaign: z.string().max(100).optional().describe("Campaign name for analytics; defaults to longUrl's utm_campaign"),
|
|
19
|
+
tags: z.array(z.string().max(50)).max(20).optional(),
|
|
20
|
+
iosFallbackUrl: httpsUrl.optional().describe('iPhone without the app: this page instead of longUrl'),
|
|
21
|
+
androidFallbackUrl: httpsUrl.optional().describe('Android without the app (website mode): this page instead of longUrl'),
|
|
22
|
+
desktopUrl: httpsUrl.optional().describe('Computers go here instead of longUrl'),
|
|
23
|
+
expiresAt: when.optional().describe('After this the link stops opening its destination. Omit for never.'),
|
|
24
|
+
expiredAction: z.enum(['page', 'store', 'url']).optional().describe('Where taps go after expiry: page (expired page), store (app store), url (expiredUrl)'),
|
|
25
|
+
expiredUrl: httpsUrl.optional().describe('Required with expiredAction url'),
|
|
26
|
+
};
|
|
27
|
+
/** Simulator presets the engine understands (GET /v1/simulate ua=). */
|
|
28
|
+
export const UA_PRESETS = ['android', 'iphone', 'ipad', 'desktop', 'instagram-ios', 'instagram-android', 'facebook-android', 'whatsapp-android', 'bot'];
|
|
29
|
+
/** The channels trace_tap walks through for one link. */
|
|
30
|
+
export const TRACE_MATRIX = [
|
|
31
|
+
{ preset: 'android', label: 'Android, Chrome' },
|
|
32
|
+
{ preset: 'whatsapp-android', label: 'Android, WhatsApp' },
|
|
33
|
+
{ preset: 'instagram-android', label: 'Android, Instagram' },
|
|
34
|
+
{ preset: 'facebook-android', label: 'Android, Facebook' },
|
|
35
|
+
{ preset: 'iphone', label: 'iPhone, Safari' },
|
|
36
|
+
{ preset: 'instagram-ios', label: 'iPhone, Instagram' },
|
|
37
|
+
{ preset: 'ipad', label: 'iPad, Safari' },
|
|
38
|
+
{ preset: 'desktop', label: 'Computer' },
|
|
39
|
+
{ preset: 'bot', label: 'Link preview bot' },
|
|
40
|
+
];
|
|
41
|
+
/** Resolve slug-or-URL to the full short link. */
|
|
42
|
+
async function shortUrlOf(client, args) {
|
|
43
|
+
if (args.url)
|
|
44
|
+
return args.url;
|
|
45
|
+
if (!args.slug)
|
|
46
|
+
throw new Error('give either slug or url');
|
|
47
|
+
return (await client.getLink(args.slug)).shortUrl;
|
|
48
|
+
}
|
|
49
|
+
/** Drop the raw file bodies from an App Links report (they can be 32 KB each). */
|
|
50
|
+
function compactReport(report) {
|
|
51
|
+
if (!report || typeof report !== 'object')
|
|
52
|
+
return report;
|
|
53
|
+
const r = structuredClone(report);
|
|
54
|
+
for (const k of ['android', 'ios']) {
|
|
55
|
+
const p = r[k];
|
|
56
|
+
if (!p)
|
|
57
|
+
continue;
|
|
58
|
+
for (const f of ['file', 'legacyFile'])
|
|
59
|
+
if (p[f])
|
|
60
|
+
delete p[f].body;
|
|
61
|
+
}
|
|
62
|
+
return r;
|
|
63
|
+
}
|
|
64
|
+
const UPDATE_FIELDS = ['longUrl', 'title', 'tags', 'expiresAt', 'expiredAction', 'expiredUrl'];
|
|
65
|
+
export const TOOLS = [
|
|
66
|
+
tool({
|
|
67
|
+
name: 'create_link',
|
|
68
|
+
description: 'Create one deep link (short link) in the workspace of STRAIT_SECRET_KEY. Returns the link with its shortUrl ' +
|
|
69
|
+
'(https://<workspace>.strait.link/<slug>) to share. The link opens the app when installed (App Links / Universal Links), ' +
|
|
70
|
+
'otherwise the store or website, and keeps the destination through an install. For more than a few links use batch_create_links.',
|
|
71
|
+
inputSchema: newLinkShape,
|
|
72
|
+
annotations: { title: 'Create a link', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
73
|
+
needsKey: true,
|
|
74
|
+
run: (c, a) => c.createLink(a),
|
|
75
|
+
}),
|
|
76
|
+
tool({
|
|
77
|
+
name: 'batch_create_links',
|
|
78
|
+
description: 'Create up to 500 links in one call (imports, migrations, campaigns). Each item is checked like create_link and gets its own ' +
|
|
79
|
+
'result; a slug that already exists is a per-item 409, so re-running an import is safe. Read results[i].ok.',
|
|
80
|
+
inputSchema: { links: z.array(z.object(newLinkShape)).min(1).max(500) },
|
|
81
|
+
annotations: { title: 'Create links in bulk', readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
82
|
+
needsKey: true,
|
|
83
|
+
run: (c, a) => c.batchCreateLinks(a.links),
|
|
84
|
+
}),
|
|
85
|
+
tool({
|
|
86
|
+
name: 'get_link',
|
|
87
|
+
description: "Get one active link by slug: destination, fallbacks, tags, expiry and its shortUrl.",
|
|
88
|
+
inputSchema: { slug },
|
|
89
|
+
annotations: { title: 'Get a link', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
90
|
+
needsKey: true,
|
|
91
|
+
run: (c, a) => c.getLink(a.slug),
|
|
92
|
+
}),
|
|
93
|
+
tool({
|
|
94
|
+
name: 'list_links',
|
|
95
|
+
description: 'List active links, newest first, one page at a time. Pass nextCursor back as cursor until it is null.',
|
|
96
|
+
inputSchema: {
|
|
97
|
+
limit: z.number().int().min(1).max(100).optional().describe('Page size, default 50'),
|
|
98
|
+
cursor: z.string().optional().describe('nextCursor from the previous page'),
|
|
99
|
+
},
|
|
100
|
+
annotations: { title: 'List links', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
101
|
+
needsKey: true,
|
|
102
|
+
run: (c, a) => c.listLinks(a),
|
|
103
|
+
}),
|
|
104
|
+
tool({
|
|
105
|
+
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, ' +
|
|
107
|
+
'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.',
|
|
109
|
+
inputSchema: {
|
|
110
|
+
slug,
|
|
111
|
+
longUrl: httpsUrl.optional(),
|
|
112
|
+
title: z.string().max(200).optional(),
|
|
113
|
+
tags: z.array(z.string().max(50)).max(20).optional(),
|
|
114
|
+
expiresAt: when.nullable().optional(),
|
|
115
|
+
expiredAction: z.enum(['page', 'store', 'url']).nullable().optional(),
|
|
116
|
+
expiredUrl: httpsUrl.nullable().optional(),
|
|
117
|
+
confirm: z.boolean().optional().describe('true to apply the change; omit or false to preview it'),
|
|
118
|
+
},
|
|
119
|
+
annotations: { title: 'Update a link', readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
120
|
+
needsKey: true,
|
|
121
|
+
run: async (c, a) => {
|
|
122
|
+
const patch = {};
|
|
123
|
+
for (const f of UPDATE_FIELDS)
|
|
124
|
+
if (a[f] !== undefined)
|
|
125
|
+
patch[f] = a[f];
|
|
126
|
+
if (!Object.keys(patch).length)
|
|
127
|
+
throw new Error('nothing to change: give at least one of ' + UPDATE_FIELDS.join(', '));
|
|
128
|
+
if (a.confirm !== true) {
|
|
129
|
+
const current = await c.getLink(a.slug);
|
|
130
|
+
const before = Object.fromEntries(Object.keys(patch).map((k) => [k, current[k] ?? null]));
|
|
131
|
+
return {
|
|
132
|
+
preview: true,
|
|
133
|
+
applied: false,
|
|
134
|
+
slug: a.slug,
|
|
135
|
+
shortUrl: current.shortUrl,
|
|
136
|
+
current: before,
|
|
137
|
+
proposed: patch,
|
|
138
|
+
next: 'Nothing has changed yet. Show this to the user; if they agree, call update_link again with the same fields and confirm: true.',
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
return { applied: true, link: await c.updateLink(a.slug, patch) };
|
|
142
|
+
},
|
|
143
|
+
}),
|
|
144
|
+
tool({
|
|
145
|
+
name: 'get_analytics',
|
|
146
|
+
description: 'Taps, app opens, installs, conversions and revenue for the workspace (or one link) over a date range, optionally grouped by ' +
|
|
147
|
+
'day, channel (whatsapp, instagram, gmail, direct…), link or platform. Same definitions as the dashboard; days are UTC; ' +
|
|
148
|
+
'at most 92 days per call (default: the last 30 days).',
|
|
149
|
+
inputSchema: {
|
|
150
|
+
from: z.string().optional().describe('Inclusive start: 2026-09-01, an ISO date-time or epoch ms'),
|
|
151
|
+
to: z.string().optional().describe('Exclusive end, same formats; default now'),
|
|
152
|
+
link: slug.optional().describe('Only this link (slug)'),
|
|
153
|
+
group: z.enum(['none', 'day', 'channel', 'link', 'platform']).optional(),
|
|
154
|
+
},
|
|
155
|
+
annotations: { title: 'Read analytics', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
156
|
+
needsKey: true,
|
|
157
|
+
run: (c, a) => c.getAnalytics(a),
|
|
158
|
+
}),
|
|
159
|
+
tool({
|
|
160
|
+
name: 'check_app_links',
|
|
161
|
+
description: "Check a domain's App Links (Android, /.well-known/assetlinks.json) and Universal Links (iPhone, apple-app-site-association) " +
|
|
162
|
+
'files: each finding has a severity (ok / warn / error) and a fix. Use it when links open the browser instead of the app. ' +
|
|
163
|
+
'It checks the files the domain serves, not how one particular phone behaves. No key needed.',
|
|
164
|
+
inputSchema: { domain: z.string().min(3).max(253).describe('A domain such as hilltop.strait.link or shop.hilltop.example (a full URL is reduced to its host)') },
|
|
165
|
+
annotations: { title: 'Check App Links / Universal Links files', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
166
|
+
needsKey: false,
|
|
167
|
+
run: async (c, a) => compactReport(await c.checkAppLinks(a.domain)),
|
|
168
|
+
}),
|
|
169
|
+
tool({
|
|
170
|
+
name: 'simulate_tap',
|
|
171
|
+
description: 'Dry-run one tap on a link from one kind of device or app and see exactly what Strait would do (redirect, in-app-browser ' +
|
|
172
|
+
'escape page, store, preview card) and why. Nothing is recorded or billed. ua is a preset or a raw User-Agent string.',
|
|
173
|
+
inputSchema: {
|
|
174
|
+
slug: slug.optional(),
|
|
175
|
+
url: httpsUrl.optional().describe('The full short link instead of slug'),
|
|
176
|
+
ua: z.string().min(2).max(512).describe(`A preset (${UA_PRESETS.join(', ')}) or a raw User-Agent`),
|
|
177
|
+
},
|
|
178
|
+
annotations: { title: 'Simulate a tap', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
179
|
+
needsKey: true,
|
|
180
|
+
run: async (c, a) => c.simulateTap(await shortUrlOf(c, a), a.ua),
|
|
181
|
+
}),
|
|
182
|
+
tool({
|
|
183
|
+
name: 'trace_tap',
|
|
184
|
+
description: 'Explain where taps on a link go. With slug or url: walks the link through the common channels (Android Chrome, WhatsApp, ' +
|
|
185
|
+
'Instagram and Facebook in-app browsers, iPhone Safari and Instagram, iPad, computer, preview bot) and returns one row per ' +
|
|
186
|
+
'channel with the decision, where the tap is sent and the reason. A simulation: nothing is recorded. With token: reads a ' +
|
|
187
|
+
'Live Tap Trace from the straitlink.in demo (the st value of a demo.strait.link/try link), i.e. what happened to a real tap.',
|
|
188
|
+
inputSchema: {
|
|
189
|
+
slug: slug.optional(),
|
|
190
|
+
url: httpsUrl.optional(),
|
|
191
|
+
token: z.string().regex(/^[a-z0-9]{20,32}$/).optional().describe('Demo trace token (st=…) instead of a link'),
|
|
192
|
+
},
|
|
193
|
+
annotations: { title: 'Trace taps on a link', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
194
|
+
needsKey: false,
|
|
195
|
+
run: async (c, a) => {
|
|
196
|
+
if (a.token) {
|
|
197
|
+
const trace = await c.getTapTrace(a.token);
|
|
198
|
+
return trace ?? { trace: null, note: 'No tap for this token yet (or it is older than 15 minutes). Tap the demo link on a phone, then try again.' };
|
|
199
|
+
}
|
|
200
|
+
if (!c.hasSecretKey)
|
|
201
|
+
throw new Error('tracing a link needs STRAIT_SECRET_KEY (a demo token does not)');
|
|
202
|
+
const url = await shortUrlOf(c, a);
|
|
203
|
+
const rows = [];
|
|
204
|
+
for (const m of TRACE_MATRIX) {
|
|
205
|
+
const r = await c.simulateTap(url, m.preset);
|
|
206
|
+
rows.push({ channel: m.label, preset: m.preset, decision: r.decision, sentTo: r.sentTo ?? null, reason: r.reason ?? null, location: r.location ?? null });
|
|
207
|
+
}
|
|
208
|
+
return { link: url, simulated: true, rows };
|
|
209
|
+
},
|
|
210
|
+
}),
|
|
211
|
+
tool({
|
|
212
|
+
name: 'search_docs',
|
|
213
|
+
description: 'Search the Strait documentation (SDK setup for React Native / Expo, Flutter, Android, iOS, Unity, web and Node; App Links and ' +
|
|
214
|
+
'Universal Links; deferred deep linking; analytics; REST API; webhooks; migrating from Firebase Dynamic Links or Branch; ' +
|
|
215
|
+
'troubleshooting). Returns the closest-matching sections as Markdown with their URLs. Works offline.',
|
|
216
|
+
inputSchema: {
|
|
217
|
+
query: z.string().min(2).max(300),
|
|
218
|
+
limit: z.number().int().min(1).max(10).optional().describe('Sections to return, default 4'),
|
|
219
|
+
},
|
|
220
|
+
annotations: { title: 'Search the docs', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
221
|
+
needsKey: false,
|
|
222
|
+
run: async (_c, a) => {
|
|
223
|
+
const hits = searchDocs(a.query, a.limit ?? 4);
|
|
224
|
+
return {
|
|
225
|
+
query: a.query,
|
|
226
|
+
results: hits.map((h) => ({
|
|
227
|
+
page: h.page,
|
|
228
|
+
section: h.section,
|
|
229
|
+
url: h.url,
|
|
230
|
+
markdown: h.markdown.length > 2500 ? `${h.markdown.slice(0, 2500)}\n\n… (truncated; read the rest at ${h.url})` : h.markdown,
|
|
231
|
+
})),
|
|
232
|
+
};
|
|
233
|
+
},
|
|
234
|
+
}),
|
|
235
|
+
];
|
|
236
|
+
/** The guidance every client gets with the server (MCP `instructions`). */
|
|
237
|
+
export const SERVER_INSTRUCTIONS = [
|
|
238
|
+
'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
|
+
'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
|
+
'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.',
|
|
242
|
+
'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
|
+
'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
|
+
].join('\n');
|
package/package.json
CHANGED
|
@@ -1,6 +1,64 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@straitlink/mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
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.",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"mcp",
|
|
8
|
+
"model-context-protocol",
|
|
9
|
+
"strait",
|
|
10
|
+
"deep-linking",
|
|
11
|
+
"app-links",
|
|
12
|
+
"universal-links",
|
|
13
|
+
"analytics"
|
|
14
|
+
],
|
|
15
|
+
"homepage": "https://straitlink.in/docs/",
|
|
16
|
+
"bugs": {
|
|
17
|
+
"url": "https://github.com/bazingga08/strait-mcp/issues"
|
|
18
|
+
},
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/bazingga08/strait-mcp.git"
|
|
22
|
+
},
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"author": "The Strait team <support@straitlink.in>",
|
|
25
|
+
"type": "module",
|
|
26
|
+
"bin": {
|
|
27
|
+
"strait-mcp": "dist/stdio.js"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"dist/"
|
|
31
|
+
],
|
|
32
|
+
"scripts": {
|
|
33
|
+
"build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.build.json",
|
|
34
|
+
"typecheck": "tsc --noEmit && tsc -p tsconfig.test.json && tsc -p tsconfig.worker.json",
|
|
35
|
+
"test": "vitest run",
|
|
36
|
+
"docs-index": "node scripts/build-docs-index.mjs",
|
|
37
|
+
"start": "node dist/stdio.js",
|
|
38
|
+
"inspect": "npx -y @modelcontextprotocol/inspector@0.17.5 --cli node dist/stdio.js --method tools/list",
|
|
39
|
+
"dev:remote": "npx -y wrangler@4 dev",
|
|
40
|
+
"deploy:check": "npx -y wrangler@4 deploy --dry-run --env production --outdir .wrangler/dry-run",
|
|
41
|
+
"deploy:preflight": "tsx scripts/deploy-preflight.ts",
|
|
42
|
+
"skill:sync": "node scripts/sync-skill.mjs",
|
|
43
|
+
"prepack": "npm run build"
|
|
44
|
+
},
|
|
45
|
+
"engines": {
|
|
46
|
+
"node": ">=20"
|
|
47
|
+
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"@modelcontextprotocol/sdk": "^1.32.0",
|
|
50
|
+
"zod": "^4.6.5"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@cloudflare/workers-oauth-provider": "^1.2.1",
|
|
54
|
+
"@cloudflare/workers-types": "^5.20261004.1",
|
|
55
|
+
"@types/node": "^22.20.5",
|
|
56
|
+
"tsx": "^4.23.15",
|
|
57
|
+
"typescript": "5.9",
|
|
58
|
+
"vitest": "^4.1.8"
|
|
59
|
+
},
|
|
60
|
+
"publishConfig": {
|
|
61
|
+
"access": "public",
|
|
62
|
+
"provenance": true
|
|
63
|
+
}
|
|
64
|
+
}
|