@soba-so/mcp 0.0.2 → 0.0.3
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 +1 -1
- package/dist/mcp.mjs +80 -17
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -69,6 +69,6 @@ ceiling and a reward, and those are decisions about your business.
|
|
|
69
69
|
| | |
|
|
70
70
|
|---|---|
|
|
71
71
|
| `--token mk_soba_…` | Your management key. `SOBA_MANAGEMENT_KEY` works too, and keeps it out of the process list. |
|
|
72
|
-
| `--url` | The Soba instance. Defaults to `https://
|
|
72
|
+
| `--url` | The Soba instance. Defaults to `https://soba.so`. |
|
|
73
73
|
|
|
74
74
|
No runtime dependencies. Node 22+.
|
package/dist/mcp.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
var u=class extends Error{constructor(e,r){super(r);this.status=e;this.name="ApiError"}},c=class{constructor(t,e){this.token=t;this.baseUrl=e}get(t){return this.call("GET",t)}post(t,e){return this.call("POST",t,e)}patch(t,e){return this.call("PATCH",t,e)}async call(t,e,r){let s=await fetch(`${this.baseUrl}${e}`,{method:t,headers:{authorization:`Bearer ${this.token}`,...r===void 0?{}:{"content-type":"application/json"}},body:r===void 0?void 0:JSON.stringify(r)}),o=await s.text(),i=null;try{i=o?JSON.parse(o):null}catch{throw new u(s.status,`${t} ${e} did not return json (${o.slice(0,120)})`)}if(!s.ok){let l=i?.error;throw new u(s.status,typeof l=="string"?l:`${t} ${e} failed with ${s.status}`)}return i}};var y=36e5,b=24*y,B={returnAfterMs:24*y,expiryMs:30*b,uniquenessMs:365*b};var H=10*6e4;var
|
|
2
|
+
var u=class extends Error{constructor(e,r){super(r);this.status=e;this.name="ApiError"}},c=class{constructor(t,e){this.token=t;this.baseUrl=e}get(t){return this.call("GET",t)}post(t,e){return this.call("POST",t,e)}patch(t,e){return this.call("PATCH",t,e)}async call(t,e,r){let s=await fetch(`${this.baseUrl}${e}`,{method:t,headers:{authorization:`Bearer ${this.token}`,...r===void 0?{}:{"content-type":"application/json"}},body:r===void 0?void 0:JSON.stringify(r)}),o=await s.text(),i=null;try{i=o?JSON.parse(o):null}catch{throw new u(s.status,`${t} ${e} did not return json (${o.slice(0,120)})`)}if(!s.ok){let l=i?.error;throw new u(s.status,typeof l=="string"?l:`${t} ${e} failed with ${s.status}`)}return i}};var y=36e5,b=24*y,B={returnAfterMs:24*y,expiryMs:30*b,uniquenessMs:365*b};var H=10*6e4;var M=864e5,E=30,ee={abandonedAfterMs:E*M};var re=30*24*60*60*1e3;var oe=5*60*1e3;var _e=60*6e4;var T="https://soba.so/skill",ve=`npx skills add ${T}`;var w=`Soba is the LLM layer for an application whose users bring their own compute.
|
|
3
3
|
Requests go to Soba instead of to a model provider; Soba routes each one to the
|
|
4
4
|
machine or plan belonging to the user it names, meters it, and bills for it.
|
|
5
5
|
|
|
@@ -29,15 +29,52 @@ not throw \u2014 it silently pairs runs to the wrong person.
|
|
|
29
29
|
|
|
30
30
|
### Where the key comes from
|
|
31
31
|
|
|
32
|
-
If
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
32
|
+
If \`.env.local\` already sets \`SOBA_KEY\`, use it. Otherwise run this from the
|
|
33
|
+
project root:
|
|
34
|
+
|
|
35
|
+
\`\`\`bash
|
|
36
|
+
npx -y @soba-so/cli login
|
|
37
|
+
\`\`\`
|
|
38
|
+
|
|
39
|
+
It opens the person's browser, they pick the app, and it writes \`SOBA_KEY\` (a
|
|
40
|
+
development key, for this machine) and \`SOBA_PROD_KEY\` (production) into
|
|
41
|
+
\`.env.local\`, adding that file to \`.gitignore\` if git would commit it. The key
|
|
42
|
+
never passes through you. Tell the person to approve in the browser before you
|
|
43
|
+
run it; it waits up to fifteen minutes for them, so give the command a long
|
|
44
|
+
timeout or run it in the background. Add \`--headless\` where no browser can
|
|
45
|
+
open, and pass the link it prints on to the person.
|
|
46
|
+
|
|
47
|
+
\`.env.local\` is right for Next.js, Vite and most Node servers. Where the code
|
|
48
|
+
that holds the key reads a different file, pass that file with \`--env-file\`
|
|
49
|
+
instead, from the project root, so the key lands where the server will look:
|
|
50
|
+
|
|
51
|
+
| Server | File |
|
|
52
|
+
|---|---|
|
|
53
|
+
| Cloudflare Worker (\`wrangler dev\`) | \`--env-file <worker dir>/.dev.vars\` |
|
|
54
|
+
| A server in a subfolder of a monorepo or an extension repo | \`--env-file <server dir>/.env.local\` |
|
|
55
|
+
|
|
56
|
+
Find the file by reading how that server loads its environment, not by its
|
|
57
|
+
framework's reputation. The CLI adds whatever file it writes to \`.gitignore\`
|
|
58
|
+
if git would commit it.
|
|
59
|
+
|
|
60
|
+
On a host that keeps secrets itself \u2014 \`wrangler secret put SOBA_KEY\`, Vercel's
|
|
61
|
+
or Railway's environment \u2014 production takes \`SOBA_PROD_KEY\`'s value under the
|
|
62
|
+
name \`SOBA_KEY\`. That is the person's to paste; tell them the command.
|
|
63
|
+
|
|
64
|
+
A development key reaches only the user's own AI \u2014 their machine, their
|
|
65
|
+
subscription, their API key. In production, \`SOBA_KEY\` is set to the value of
|
|
66
|
+
\`SOBA_PROD_KEY\` in the host's environment; say so to the person rather than
|
|
67
|
+
doing it.
|
|
68
|
+
|
|
69
|
+
If you cannot run commands but have a \`soba_create_key\` tool, call it: the
|
|
70
|
+
secret is returned once and nothing can show it again, so write it into
|
|
71
|
+
\`.env.local\` in the same step. Failing both, ask the person to run the command
|
|
72
|
+
above themselves. Do not leave \`SOBA_KEY\`
|
|
36
73
|
empty and do not invent a value; both fail at the first request, and the first
|
|
37
74
|
fails as an auth error rather than as a missing variable.
|
|
38
75
|
|
|
39
|
-
Put \`SOBA_KEY\` in \`.env.local
|
|
40
|
-
writing it. Never in \`.env\`, never in \`.env.example\`, never in anything
|
|
76
|
+
Put \`SOBA_KEY\` in \`.env.local\` (or the server's own file, above), and check
|
|
77
|
+
that file is gitignored before writing it. Never in \`.env\`, never in \`.env.example\`, never in anything
|
|
41
78
|
tracked, and never echoed back to the terminal.
|
|
42
79
|
|
|
43
80
|
### 2. Mount the connect component
|
|
@@ -87,6 +124,13 @@ with all of them cannot put two pairings on screen at once.
|
|
|
87
124
|
| \`<SobaPlans showConnect />\` | A pricing page with no current-plan card on it |
|
|
88
125
|
| \`<SobaCurrentPlan />\` | The card alone, when composing the page yourself |
|
|
89
126
|
| \`<SobaCheckout />\` | Takes the payment and grants the entitlement |
|
|
127
|
+
| \`<SobaGate feature="workspaces" />\` | Around anything a plan limits \u2014 a "New workspace" button, an export. Shows it while allowed, otherwise the reason and the plans |
|
|
128
|
+
|
|
129
|
+
A gate draws the button; it does not secure the action. On the server, ask
|
|
130
|
+
before you create: \`soba.users.check(user, "workspaces", { use: 1 })\` answers
|
|
131
|
+
\`{ allowed, reason }\` and counts the use in the same call, so two requests for
|
|
132
|
+
the last one cannot both get it. Record use you did not ask about with
|
|
133
|
+
\`soba.users.track(user, "workspaces", -1)\` when one is deleted.
|
|
90
134
|
|
|
91
135
|
Prices, allowances and the connected reward come from the plan objects, so
|
|
92
136
|
changing them later is not a deploy. Pass \`showConnect\` only on a page with no
|
|
@@ -123,16 +167,35 @@ rules are ours; the pixels stay theirs.
|
|
|
123
167
|
| \`<SobaPlans />\` | \`usePlans()\` | The catalogue as decisions: price, allowance, compute, the connect row, one \`take()\` per plan |
|
|
124
168
|
| \`<SobaCurrentPlan />\` | \`useCurrentPlan()\` | The allowance arithmetic and the one sentence that is true of it |
|
|
125
169
|
| \`<SobaCheckout />\` | \`useCheckout()\` | The session, the label, and the rule that nothing is granted in the browser |
|
|
170
|
+
| \`<SobaGate />\` | \`useFeature()\` | Whether this person may use a feature, and why not: no plan, plan ended, not included, limit reached |
|
|
126
171
|
|
|
127
172
|
\`<ConnectCompute />\`, \`<SobaBilling />\` and \`<ConnectModal />\` are compositions
|
|
128
|
-
of the above and have no rule of their own. \`<
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
173
|
+
of the above and have no rule of their own. \`<ConnectPill />\` is the half of
|
|
174
|
+
\`<ConnectButton />\` that reports \u2014 the mark, the state and the machine, with no
|
|
175
|
+
popover under it and no flow behind it \u2014 for an app putting the readout in its
|
|
176
|
+
own header and opening its own panel. Pressing it is yours to wire; everything
|
|
177
|
+
it says is \`connectSays\`. \`<SobaProvider>\` is needed either way: the hooks
|
|
178
|
+
read from it, so a hook with no provider above it is the one mistake this split
|
|
179
|
+
invites.
|
|
180
|
+
|
|
181
|
+
Two belong to no component of their own. \`useOpenSobaConnect()\` brings the
|
|
133
182
|
Soba app forward on this person's machine and hands back the one honest thing a
|
|
134
183
|
page can say about having tried \u2014 a browser cannot tell whether a \`soba://\`
|
|
135
184
|
link was answered, and a page that claims it worked is guessing.
|
|
185
|
+
\`useMachineRuns()\` is the "Running now" list out of \`<ConnectButton />\`'s
|
|
186
|
+
panel with no panel around it: what this person's machines are running FOR THIS
|
|
187
|
+
APP, and \`stop(runId)\` to end one as the person whose machine it is on. The app
|
|
188
|
+
that started that run sees it finish with \`reason: "stopped_by_user"\`, which is
|
|
189
|
+
the point: a surface built on this is a kill switch its owner holds, not a
|
|
190
|
+
request to somebody's server.
|
|
191
|
+
|
|
192
|
+
\`useRedeemCode()\` is a promo code field's behaviour: \`redeem(code)\` gives the
|
|
193
|
+
user what the app's reward hands out \u2014 AI usage on top of their plan, a plan for
|
|
194
|
+
some days, or a discount at their next checkout \u2014 and re-reads their plan, so
|
|
195
|
+
the allowance on screen moves without a reload. Its \`error\` is a sentence ready
|
|
196
|
+
to show. Put the field where somebody arrives with a code, usually the billing
|
|
197
|
+
page. \`<RedeemCode />\` is that field already drawn \u2014 an input and an Apply
|
|
198
|
+
button \u2014 for an app with no billing page of its own to put one in.
|
|
136
199
|
|
|
137
200
|
\`useSoba()\` is the raw provider state. Reach for a named hook first and use
|
|
138
201
|
this one for what no hook covers \u2014 \`run()\`, \`connect.open()\`,
|
|
@@ -231,7 +294,7 @@ remains is the key.
|
|
|
231
294
|
|
|
232
295
|
Pairing is a person installing the Soba app on their own laptop and approving
|
|
233
296
|
it. Pricing, allowances and rewards are decisions about somebody's business.
|
|
234
|
-
Do neither on their behalf; say what is left and stop.`;var
|
|
297
|
+
Do neither on their behalf; say what is left and stop.`;var C=2.5*1e6,d=[{upTo:null,microsPerUnit:C}];var Ae=[{id:"hobby",name:"Hobby",feeMicros:19*1e6,includedActivations:12,seatLimit:2,appLimit:3,bands:d,features:["All three envelope dials: duration, volume and feature scope","3 projects","2 seats","Owner and Member roles","Activation counts","Docs support"]},{id:"starter",seatLimit:null,appLimit:null,name:"Starter",feeMicros:199*1e6,includedActivations:90,bands:d,features:["Unlimited projects","Unlimited seats","Admin and Billing roles","Cohort and conversion attribution","Email support"]},{id:"growth",seatLimit:null,appLimit:null,name:"Growth",feeMicros:599*1e6,includedActivations:300,bands:d,features:["Window A/B experimentation","Email support, one business day"]},{id:"scale",seatLimit:null,appLimit:null,name:"Scale",feeMicros:1999*1e6,includedActivations:1500,bands:d,features:["Custom roles and audit log","Effective-vs-configured window analytics","Shared channel with an SLA"]}];var p=[{name:"set_up",title:"Set Soba up in this repo",description:"Works through Soba's setup checklist one step at a time, editing this repo and showing you each diff.",text:`Set this project up on Soba, working from the real checklist rather than from memory.
|
|
235
298
|
|
|
236
299
|
The loop, and keep to it:
|
|
237
300
|
|
|
@@ -265,11 +328,11 @@ there either:
|
|
|
265
328
|
${w}`}],_=new Map(p.map(n=>[n.name,n]));function a(n){process.stderr.write(`soba-mcp: ${n}
|
|
266
329
|
`)}function x(n){process.stdout.write(`${JSON.stringify(n)}
|
|
267
330
|
`)}function k(n){let t="",e=new Set,r=!1,s=o=>{e.add(o),o.finally(()=>{e.delete(o),r&&e.size===0&&process.exit(0)})};process.stdin.setEncoding("utf8"),process.stdin.on("data",o=>{t+=o;let i=t.indexOf(`
|
|
268
|
-
`);for(;i!==-1;){let l=t.slice(0,i).trim();t=t.slice(i+1),l&&s(
|
|
269
|
-
`)}}),process.stdin.on("end",()=>{r=!0,e.size===0&&process.exit(0),setTimeout(()=>process.exit(0),1e4).unref()})}async function
|
|
331
|
+
`);for(;i!==-1;){let l=t.slice(0,i).trim();t=t.slice(i+1),l&&s(I(l,n)),i=t.indexOf(`
|
|
332
|
+
`)}}),process.stdin.on("end",()=>{r=!0,e.size===0&&process.exit(0),setTimeout(()=>process.exit(0),1e4).unref()})}async function I(n,t){let e;try{e=JSON.parse(n)}catch{a("ignored a line that was not json");return}let r=e.id===void 0||e.id===null;try{let s=await t(e.method,e.params??{});r||x({jsonrpc:"2.0",id:e.id,result:s})}catch(s){if(r)return;let o=s instanceof Error?s.message:String(s),i=o==="method not found"?-32601:-32603;x({jsonrpc:"2.0",id:e.id,error:{code:i,message:o}})}}var v={type:"object",properties:{},additionalProperties:!1};function S(n){let t=n.steps.map(r=>`${r.done?"[x]":"[ ]"} ${r.title} \u2014 ${r.state}`),e=n.complete?"Every step is done. Nothing here needs you again.":`Next: ${n.next}. Call soba_next_step for what to do.`;return`${t.join(`
|
|
270
333
|
`)}
|
|
271
334
|
|
|
272
|
-
${e}`}function
|
|
335
|
+
${e}`}function P(n,t){return n.steps.find(e=>e.id===t)}var m=[{name:"soba_status",title:"Read the setup checklist",description:"Soba's setup checklist for this app and which steps are done. Call this first, and again after every change, so you are acting on what is true rather than on what you did last.",inputSchema:v,run:async n=>S(await n.get("/v1/manage/progress"))},{name:"soba_next_step",title:"What to do next",description:"The first outstanding step, with an instruction written for you. Do what it says using your own tools \u2014 read the repo, make the edits, show the diff. Then call soba_status again to confirm it took.",inputSchema:v,run:async n=>{let t=await n.get("/v1/manage/progress");if(t.complete)return"Setup is complete. There is nothing to do.";let e=P(t,t.next);return e?e.effect==="human"?e.id==="price"?`Next: ${e.title}
|
|
273
336
|
|
|
274
337
|
${e.lede}
|
|
275
338
|
|
|
@@ -289,4 +352,4 @@ When you are done, call soba_status to confirm it registered.`:S(t)}},{name:"sob
|
|
|
289
352
|
|
|
290
353
|
SOBA_KEY=${e.key}
|
|
291
354
|
|
|
292
|
-
This is the only time it exists. Put it in .env.local now.`}},{name:"soba_set_connect_url",title:"Point Connect back at the app",description:"Sets where someone who started pairing in their terminal gets sent to sign in. It must be the public https page that renders <ConnectCompute />. If no such page exists yet, do not guess one \u2014 build the page first.",inputSchema:{type:"object",properties:{url:{type:"string",description:"The https URL of the page. Loopback is allowed in development."}},required:["url"],additionalProperties:!1},run:async(n,t)=>(await n.patch("/v1/manage/app",{connect_return_url:t.url}),`Connect now returns to ${t.url}.`)},{name:"soba_create_plan",title:"Create a plan",description:"Creates a plan: the price, the allowance, what a run may fall back to, and what connecting a machine earns back. Only call this with numbers a person gave you. Do not invent a price.",inputSchema:{type:"object",properties:{name:{type:"string"},description:{type:"string"},price:{type:"number",description:"Dollars a month. 0 is a free plan."},included_units:{type:["number","null"],description:"Runs
|
|
355
|
+
This is the only time it exists. Put it in .env.local now.`}},{name:"soba_set_connect_url",title:"Point Connect back at the app",description:"Sets where someone who started pairing in their terminal gets sent to sign in. It must be the public https page that renders <ConnectCompute />. If no such page exists yet, do not guess one \u2014 build the page first.",inputSchema:{type:"object",properties:{url:{type:"string",description:"The https URL of the page. Loopback is allowed in development."}},required:["url"],additionalProperties:!1},run:async(n,t)=>(await n.patch("/v1/manage/app",{connect_return_url:t.url}),`Connect now returns to ${t.url}.`)},{name:"soba_create_plan",title:"Create a plan",description:"Creates a plan: the price, the allowance, what a run may fall back to, and what connecting a machine earns back. Only call this with numbers a person gave you. Do not invent a price.",inputSchema:{type:"object",properties:{name:{type:"string"},description:{type:"string"},price:{type:"number",description:"Dollars a month. 0 is a free plan."},unit:{type:"string",enum:["run","token","cost"],description:"What both allowances are counted in. `cost` counts hundredths of a cent (10,000 to the dollar); a token or cost overage price is per 1M tokens or per $1."},included_units:{type:["number","null"],description:"Runs a month on AI the app pays for (open-weights, frontier). Null is unlimited."},trial_days:{type:"number"},route_prefer:{type:"array",items:{type:"string",enum:["user-subscription","user-hardware","open-weights","frontier"]},description:"What a run may use, best first. This is also the ceiling: a class left out can never be billed to this app."},max_cost_per_run:{type:["number","null"],description:"Dollars one run may cost."},own_included_units:{type:["number","null"],description:"Runs a month on the user's own AI (their machine, subscription or key), counted apart from included_units. Null is unlimited."},own_unit:{type:["string","null"],enum:["run","token","credit","task",null],description:"What own_included_units counts, when not unit: a hybrid plan gives 200 runs on the user's own AI and 2,000 credits on yours. Null means the same as unit."},connected_reward:{type:"string",enum:["none","included","credit"]},connected_included_units:{type:["number","null"]},connected_credit:{type:["number","null"]}},required:["name","price","route_prefer"],additionalProperties:!1},run:async(n,t)=>{let e=await n.post("/v1/manage/plans",t);return`Created the plan "${e.name}"${e.id?` (${e.id})`:""}.`}},{name:"soba_give_reward",title:"Give a user a reward",description:"Gives one user a reward the app already has \u2014 money off, extra AI usage, or a plan for some days. Once per user per reward. Only call this when a person asked for that user to get it.",inputSchema:{type:"object",properties:{reward:{type:"string",description:"The reward's ID, e.g. early_bird."},user:{type:"string",description:"The app's own id for the user."}},required:["reward","user"],additionalProperties:!1},run:async(n,t)=>(await n.post("/v1/manage/rewards/give",t),`Gave ${t.user} the reward ${t.reward}.`)}],A=new Map(m.map(n=>[n.name,n]));var O="https://soba.so",U="2025-06-18",N="0.0.3";function L(n){let t={token:process.env.SOBA_MANAGEMENT_KEY??null,baseUrl:process.env.SOBA_MANAGE_URL??O};for(let e=0;e<n.length;e++){let r=n[e];r==="--token"?t.token=n[++e]??null:r?.startsWith("--token=")?t.token=r.slice(8):r==="--url"?t.baseUrl=n[++e]??t.baseUrl:r?.startsWith("--url=")&&(t.baseUrl=r.slice(6))}return t.baseUrl=t.baseUrl.replace(/\/+$/,""),t}var h=L(process.argv.slice(2)),g=h.token;g||(a("no management key. Pass --token mk_soba_\u2026 or set SOBA_MANAGEMENT_KEY."),a("Mint one in your Soba dashboard, on the Set up page."),process.exit(2));g.startsWith("mk_soba_")||(a("that is not a management key. It starts with mk_soba_, and sk_soba_ is a"),a("different credential \u2014 your app's secret key. Do not paste that here."),process.exit(2));var D=new c(g,h.baseUrl);k(async(n,t)=>{switch(n){case"initialize":return{protocolVersion:U,capabilities:{tools:{},prompts:{}},serverInfo:{name:"soba",version:N},instructions:"Soba's setup checklist. Call soba_status first, then soba_next_step, and do what it says with your own tools \u2014 the repo is yours to edit, not this server's. Call soba_status again afterwards to confirm the step registered rather than assuming it did."};case"notifications/initialized":case"notifications/cancelled":return{};case"ping":return{};case"prompts/list":return{prompts:p.map(e=>({name:e.name,title:e.title,description:e.description}))};case"prompts/get":{let e=typeof t.name=="string"?t.name:"",r=_.get(e);if(!r)throw new Error(`no such prompt: ${e}`);return{description:r.description,messages:[{role:"user",content:{type:"text",text:r.text}}]}}case"tools/list":return{tools:m.map(e=>({name:e.name,title:e.title,description:e.description,inputSchema:e.inputSchema}))};case"tools/call":{let e=typeof t.name=="string"?t.name:"",r=A.get(e);if(!r)return f(`no such tool: ${e}`);let s=t.arguments??{};try{return{content:[{type:"text",text:await r.run(D,s)}]}}catch(o){return o instanceof u?f(F(o)):f(o instanceof Error?o.message:String(o))}}default:throw new Error("method not found")}});function f(n){return{content:[{type:"text",text:n}],isError:!0}}function F(n){return n.status===401?"Soba refused the management key. It may have been revoked, or it may belong to a different app. Mint a new one on the Set up page.":n.message}a(`ready \u2014 ${m.length} tools, ${p.length} prompt against ${h.baseUrl}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@soba-so/mcp",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.3",
|
|
4
4
|
"description": "Sets Soba up from inside your coding agent: it reads the checklist, does the next step, and reads it back.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"homepage": "https://soba.so/docs/quickstart",
|