@rowvyn/servicetitan-mcp 2.4.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/.env.example +25 -0
- package/CHANGELOG.md +220 -0
- package/LICENSE +21 -0
- package/README.md +347 -0
- package/build/chunks/chunk-HBUFFGNS.js +54 -0
- package/build/index.js +3 -0
- package/build/sse.js +8 -0
- package/build/streamable-http.js +6 -0
- package/package.json +75 -0
package/build/index.js
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import{a,b as c,c as m,d as g,e as p,f as u,g as l,h as f}from"./chunks/chunk-HBUFFGNS.js";import{McpServer as S}from"@modelcontextprotocol/sdk/server/mcp.js";import{StdioServerTransport as y}from"@modelcontextprotocol/sdk/server/stdio.js";async function _(){let e=p();c(e.maxResponseChars);let{createRequire:o}=await import("node:module"),v=o(import.meta.url)("../package.json"),r=new l(e.logLevel),i=new S({name:"ServiceTitan",version:v.version}),s=new g(e),d=new a(r),t=new f(i,e,r,d);t.attachClient(s),t.register({name:"st_health_check",domain:"_system",operation:"read",description:"Verify ServiceTitan API connectivity, authentication, tenant access, and server config",schema:{},handler:async()=>{let n={};try{await s.ensureToken(),n.authentication="OK"}catch{n.authentication="FAILED"}try{await s.get("/settings/v2/tenant/{tenant}/business-units",{pageSize:1}),n.tenant_access="OK"}catch{n.tenant_access="FAILED"}return m(n)}}),await u(t,r),t.logSummary();let h=t.getStats();r.info("Server ready",h);let w=new y;await i.connect(w)}_().catch(e=>{let o=e instanceof Error?e.message:String(e);process.stderr.write(`${JSON.stringify({level:"error",ts:new Date().toISOString(),msg:"Fatal startup error",error:o})}
|
|
3
|
+
`),process.exit(1)});
|
package/build/sse.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import{a as A,b as R,c as M,d as N,e as H,f as $,g as b,h as x}from"./chunks/chunk-HBUFFGNS.js";import{timingSafeEqual as J}from"node:crypto";import{randomUUID as G}from"node:crypto";import{createServer as z}from"node:http";import{createRequire as F}from"node:module";import{McpServer as K}from"@modelcontextprotocol/sdk/server/mcp.js";import{SSEServerTransport as j}from"@modelcontextprotocol/sdk/server/sse.js";process.on("uncaughtException",e=>{process.stderr.write(`UNCAUGHT EXCEPTION: ${e.stack??e.message}
|
|
3
|
+
`)});process.on("unhandledRejection",e=>{process.stderr.write(`UNHANDLED REJECTION: ${e instanceof Error?e.stack??e.message:String(e)}
|
|
4
|
+
`)});var k=Number(process.env.PORT??process.env.ST_MCP_PORT??3100),E=process.env.ST_MCP_API_KEY??"";E||(process.stderr.write(`Fatal: ST_MCP_API_KEY is required for remote access.
|
|
5
|
+
`),process.exit(1));function U(e,s){return e.length!==s.length?!1:J(Buffer.from(e),Buffer.from(s))}function Y(e){let s=e.headers["x-api-key"];if(typeof s=="string"&&U(s,E))return!0;let u=e.headers.authorization;return!!(typeof u=="string"&&u.startsWith("Bearer ")&&U(u.slice(7),E))}function l(e,s,u){e.writeHead(s,{"Content-Type":"application/json"}),e.end(JSON.stringify(u))}function W(e,s){e.setHeader("Access-Control-Allow-Origin",s),e.setHeader("Access-Control-Allow-Methods","GET, POST, OPTIONS"),e.setHeader("Access-Control-Allow-Headers","Content-Type, x-api-key, Authorization")}async function X(){let e=H();R(e.maxResponseChars);let C=F(import.meta.url)("../package.json").version,r=new b(e.logLevel),v=new K({name:"ServiceTitan",version:C}),w=new N(e),D=new A(r),g=new x(v,e,r,D);g.attachClient(w),g.register({name:"st_health_check",domain:"_system",operation:"read",description:"Verify ServiceTitan API connectivity, authentication, tenant access, and server config",schema:{},handler:async()=>{let n={};try{await w.ensureToken(),n.authentication="OK"}catch{n.authentication="FAILED"}try{await w.get("/settings/v2/tenant/{tenant}/business-units",{pageSize:1}),n.tenant_access="OK"}catch{n.tenant_access="FAILED"}return M(n)}}),await $(g,r),g.logSummary();let O=g.getStats(),p=new Map,I=0,P=z(async(n,t)=>{let i=G();e.corsOrigin&&W(t,e.corsOrigin);try{if(n.method==="OPTIONS"){t.writeHead(204),t.end();return}let c=new URL(n.url??"/",`http://${n.headers.host??"localhost"}`);if(r.info(`[${i}] ${n.method} ${n.url}`),c.pathname==="/health"&&n.method==="GET"){l(t,200,{status:"ok",tools:O.registered,environment:e.environment,readonly:e.readonlyMode});return}if(!Y(n)){l(t,401,{error:"Unauthorized",requestId:i});return}if(c.pathname==="/sse"&&n.method==="GET"){I+=1;let m=I;try{await v.close()}catch{}for(let[d,T]of p){try{await T.close()}catch{}p.delete(d)}n.socket.setNoDelay(!0),t.setHeader("Content-Type","text/event-stream"),t.setHeader("Cache-Control","no-cache"),t.setHeader("Connection","keep-alive"),t.setHeader("X-Accel-Buffering","no");let y=t.write.bind(t);t.write=function(d,T,L){let B=y(d,T,L);return t.socket&&!t.socket.destroyed&&t.socket.uncork?.(),B};let o=new j("/messages",t);p.set(o.sessionId,o);let f,h=!1,a=()=>{m===I&&v.close().catch(d=>{r.warn("Failed to close SSE server on disconnect",{sessionId:o.sessionId,error:d instanceof Error?d.message:String(d)})})},S=d=>{h||(h=!0,f&&(clearInterval(f),f=void 0),p.get(o.sessionId)===o&&p.delete(o.sessionId),r.info("SSE client disconnected",{sessionId:o.sessionId,reason:d}),a())};o.onclose=()=>{S("transport-close")},r.info("SSE client connected",{sessionId:o.sessionId,requestId:i}),f=setInterval(()=>{t.write(`: keepalive
|
|
6
|
+
|
|
7
|
+
`)},3e4),t.on("close",()=>{S("response-close")}),await v.connect(o);return}if(c.pathname==="/messages"&&n.method==="POST"){let m=c.searchParams.get("sessionId");if(!m){l(t,400,{error:"Missing sessionId query parameter",requestId:i});return}let y=p.get(m);if(!y){l(t,404,{error:"Unknown session",requestId:i});return}let o=[],f=0;for await(let a of n){let S=typeof a=="string"?Buffer.from(a):a;if(f+=S.length,f>1048576){l(t,413,{error:"Payload too large",requestId:i});return}o.push(S)}let h;try{h=JSON.parse(Buffer.concat(o).toString())}catch{l(t,400,{error:"Invalid JSON body",requestId:i});return}try{await y.handlePostMessage(n,t,h)}catch(a){r.error("Unhandled /messages request error",{requestId:i,sessionId:m,error:a instanceof Error?a.message:String(a),stack:a instanceof Error?a.stack:void 0}),t.headersSent?t.writableEnded||t.end():l(t,500,{error:"Internal server error",requestId:i})}return}l(t,404,{error:"Not found",requestId:i})}catch(c){r.error("Unhandled SSE request error",{requestId:i,error:c instanceof Error?c.message:String(c),stack:c instanceof Error?c.stack:void 0}),t.headersSent?t.writableEnded||t.end():l(t,500,{error:"Internal server error",requestId:i})}});P.listen(k,"0.0.0.0",()=>{r.info(`ServiceTitan MCP Server v${C}`),r.info(`Transport: SSE on port ${k}`),r.info(`Read-only: ${e.readonlyMode?"yes":"no"}`),r.info(`CORS origin: ${e.corsOrigin}`),r.info(`Tools registered: ${O.registered}`),r.info(`Connect Claude Desktop with: http://localhost:${k}/sse`)});let _=()=>{r.info("Shutdown signal received, closing server..."),P.close(()=>{r.info("HTTP server closed"),process.exit(0)}),setTimeout(()=>process.exit(1),1e4).unref()};process.on("SIGTERM",_),process.on("SIGINT",_)}X().catch(e=>{let s=e instanceof Error?e.message:String(e);process.stderr.write(`${JSON.stringify({level:"error",ts:new Date().toISOString(),msg:"Fatal SSE startup error",error:s})}
|
|
8
|
+
`),process.exit(1)});
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import{a as H,b as L,c as y,d as b,e as z,f as I,g as $,h as P}from"./chunks/chunk-HBUFFGNS.js";import{timingSafeEqual as G,randomUUID as x}from"node:crypto";import{createServer as J}from"node:http";import{createRequire as j}from"node:module";import{McpServer as U}from"@modelcontextprotocol/sdk/server/mcp.js";import{StreamableHTTPServerTransport as V}from"@modelcontextprotocol/sdk/server/streamableHttp.js";var q=1800*1e3,Y=60*1e3;process.on("uncaughtException",e=>{process.stderr.write(`UNCAUGHT EXCEPTION: ${e.stack??e.message}
|
|
3
|
+
`)});process.on("unhandledRejection",e=>{process.stderr.write(`UNHANDLED REJECTION: ${e instanceof Error?e.stack??e.message:String(e)}
|
|
4
|
+
`)});var E=Number(process.env.PORT??process.env.ST_MCP_PORT??3100),k=process.env.ST_MCP_API_KEY??"";k||(process.stderr.write(`Fatal: ST_MCP_API_KEY is required for remote access.
|
|
5
|
+
`),process.exit(1));function B(e,a){return e.length!==a.length?!1:G(Buffer.from(e),Buffer.from(a))}function X(e){let a=e.headers["x-api-key"];if(typeof a=="string"&&B(a,k))return!0;let f=e.headers.authorization;return!!(typeof f=="string"&&f.startsWith("Bearer ")&&B(f.slice(7),k))}function c(e,a,f){let m=JSON.stringify(f);e.writeHead(a,{"Content-Type":"application/json"}),e.end(m)}function W(e,a){a.length>0&&(e.setHeader("Access-Control-Allow-Origin",a),e.setHeader("Access-Control-Allow-Methods","GET, POST, DELETE, OPTIONS"),e.setHeader("Access-Control-Allow-Headers","Content-Type, x-api-key, Authorization, Mcp-Session-Id, Mcp-Protocol-Version, Last-Event-ID"),e.setHeader("Access-Control-Expose-Headers","Mcp-Session-Id, Mcp-Protocol-Version")),e.setHeader("X-Accel-Buffering","no")}function Q(e){return!e||typeof e!="object"||Array.isArray(e)?!1:e.method==="initialize"}async function Z(){let e=z();L(e.maxResponseChars);let m=j(import.meta.url)("../package.json").version,o=new $(e.logLevel),p=new b(e),_=new H(o),F=new U({name:"ServiceTitan",version:m}),g=new P(F,e,o,_);g.attachClient(p),g.register({name:"st_health_check",domain:"_system",operation:"read",description:"Verify ServiceTitan API connectivity, authentication, tenant access, and server config",schema:{},handler:async()=>{let n={};try{await p.ensureToken(),n.authentication="OK"}catch{n.authentication="FAILED"}try{await p.get("/settings/v2/tenant/{tenant}/business-units",{pageSize:1}),n.tenant_access="OK"}catch{n.tenant_access="FAILED"}return y(n)}}),await I(g,o),g.logSummary();let O=g.getStats();async function K(){let n=new U({name:"ServiceTitan",version:m}),t=new P(n,e,o,_);return t.attachClient(p),t.register({name:"st_health_check",domain:"_system",operation:"read",description:"Verify ServiceTitan API connectivity, authentication, tenant access, and server config",schema:{},handler:async()=>{let r={};try{await p.ensureToken(),r.authentication="OK"}catch{r.authentication="FAILED"}try{await p.get("/settings/v2/tenant/{tenant}/business-units",{pageSize:1}),r.tenant_access="OK"}catch{r.tenant_access="FAILED"}return y(r)}}),await I(t,o),n}let u=new Map;async function w(n,t,r){if(!t.closing){t.closing=!0,o.info("Closing session",{sessionId:n,reason:r});try{await t.transport.close()}catch(s){o.warn("Failed to close session transport",{sessionId:n,reason:r,error:s instanceof Error?s.message:String(s)})}u.delete(n);try{await t.server.close()}catch(s){o.warn("Failed to close session server",{sessionId:n,reason:r,error:s instanceof Error?s.message:String(s)})}}}let R=setInterval(()=>{let n=Date.now(),t=Array.from(u.entries()).filter(([,r])=>!r.closing&&n-r.lastSeen>q);t.length!==0&&Promise.allSettled(t.map(([r,s])=>w(r,s,"idle-timeout")))},Y);R.unref();let A=J(async(n,t)=>{let r=x();if(W(t,e.corsOrigin),n.method==="OPTIONS"){t.writeHead(204),t.end();return}let s=new URL(n.url??"/",`http://${n.headers.host??"localhost"}`);if(o.info(`[${r}] ${n.method} ${s.pathname}`),s.pathname==="/health"&&n.method==="GET"){c(t,200,{status:"ok",transport:"streamable-http",tools:O.registered,environment:e.environment,readonly:e.readonlyMode});return}if(!X(n)){c(t,401,{error:"Unauthorized",requestId:r});return}if(s.pathname==="/sse"){c(t,410,{error:"SSE transport deprecated. Use Streamable HTTP at POST /mcp",migration:"Change your MCP client config URL from /sse to /mcp",requestId:r});return}if(s.pathname==="/mcp"){let v;try{let l;if(n.method==="POST"){let i=[],h=0;for await(let T of n){let N=typeof T=="string"?Buffer.from(T):T;if(h+=N.length,h>1048576){c(t,413,{error:"Payload too large",requestId:r});return}i.push(N)}try{l=JSON.parse(Buffer.concat(i).toString())}catch{c(t,400,{error:"Invalid JSON body",requestId:r});return}}let d=n.headers["mcp-session-id"];if(d&&u.has(d)){let i=u.get(d);if(i.closing){c(t,404,{error:"Session not found. Send initialize request without session ID.",requestId:r});return}i.lastSeen=Date.now(),await i.transport.handleRequest(n,t,l);return}if(d&&!u.has(d)){c(t,404,{error:"Session not found. Send initialize request without session ID.",requestId:r});return}if(n.method!=="POST"){c(t,400,{error:"Session ID required. Send initialize request via POST without session ID.",requestId:r});return}if(!Q(l)){c(t,400,{error:"New sessions must start with an initialize request.",requestId:r});return}let D=await K(),S=new V({sessionIdGenerator:()=>x(),onsessioninitialized:i=>{v=i,o.info("Session initialized",{sessionId:i,requestId:r}),u.set(i,{transport:S,server:D,lastSeen:Date.now(),closing:!1})}});S.onclose=()=>{let i=S.sessionId,h=i?u.get(i):void 0;i&&h&&w(i,h,"client-disconnect")},await D.connect(S),await S.handleRequest(n,t,l)}catch(l){if(v){let d=u.get(v);d&&await w(v,d,"initialization-failed")}o.error("Unhandled /mcp request error",{requestId:r,error:l instanceof Error?l.message:String(l),stack:l instanceof Error?l.stack:void 0}),t.headersSent?t.writableEnded||t.end():c(t,500,{error:"Internal server error",requestId:r})}return}c(t,404,{error:"Not found. MCP endpoint is at /mcp",requestId:r})});A.listen(E,"0.0.0.0",()=>{o.info(`ServiceTitan MCP Server v${m}`),o.info(`Transport: Streamable HTTP on port ${E}`),o.info(`Read-only: ${e.readonlyMode?"yes":"no"}`),o.info(`CORS origin: ${e.corsOrigin}`),o.info(`Tools registered: ${O.registered}`),o.info(`MCP endpoint: http://localhost:${E}/mcp`)});let C=!1,M=()=>{C||(C=!0,clearInterval(R),o.info("Shutdown signal received, closing server..."),Promise.allSettled(Array.from(u.entries()).map(([n,t])=>w(n,t,"shutdown"))).then(()=>{A.close(()=>{o.info("HTTP server closed"),process.exit(0)})}),setTimeout(()=>process.exit(1),1e4).unref())};process.on("SIGTERM",M),process.on("SIGINT",M)}Z().catch(e=>{let a=e instanceof Error?e.message:String(e);process.stderr.write(`${JSON.stringify({level:"error",ts:new Date().toISOString(),msg:"Fatal startup error",error:a})}
|
|
6
|
+
`),process.exit(1)});
|
package/package.json
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rowvyn/servicetitan-mcp",
|
|
3
|
+
"version": "2.4.0",
|
|
4
|
+
"description": "MCP server for the ServiceTitan API — 467 tools across 15 domains with 10 intelligence tools for business analytics",
|
|
5
|
+
"author": "Montrell Cruse <montrell@rowvyn.com> (https://rowvyn.com)",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/montrellcruse/servicetitan-mcp.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/montrellcruse/servicetitan-mcp#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/montrellcruse/servicetitan-mcp/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"servicetitan",
|
|
17
|
+
"mcp",
|
|
18
|
+
"model-context-protocol",
|
|
19
|
+
"ai",
|
|
20
|
+
"llm",
|
|
21
|
+
"hvac",
|
|
22
|
+
"field-service",
|
|
23
|
+
"home-service",
|
|
24
|
+
"api",
|
|
25
|
+
"claude",
|
|
26
|
+
"business-intelligence",
|
|
27
|
+
"analytics"
|
|
28
|
+
],
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=22.0.0"
|
|
31
|
+
},
|
|
32
|
+
"type": "module",
|
|
33
|
+
"main": "build/index.js",
|
|
34
|
+
"bin": {
|
|
35
|
+
"servicetitan-mcp": "./build/index.js",
|
|
36
|
+
"servicetitan-mcp-sse": "./build/sse.js",
|
|
37
|
+
"servicetitan-mcp-http": "./build/streamable-http.js"
|
|
38
|
+
},
|
|
39
|
+
"files": [
|
|
40
|
+
"build/",
|
|
41
|
+
"LICENSE",
|
|
42
|
+
"README.md",
|
|
43
|
+
"CHANGELOG.md",
|
|
44
|
+
".env.example"
|
|
45
|
+
],
|
|
46
|
+
"scripts": {
|
|
47
|
+
"build": "node ./scripts/build.mjs",
|
|
48
|
+
"dev": "tsc --watch",
|
|
49
|
+
"start": "node build/index.js",
|
|
50
|
+
"lint": "eslint src/",
|
|
51
|
+
"prepublishOnly": "npm run typecheck && npm run lint && npm run test && npm run build",
|
|
52
|
+
"typecheck": "tsc --noEmit",
|
|
53
|
+
"test": "vitest run",
|
|
54
|
+
"test:watch": "vitest",
|
|
55
|
+
"docker:build": "docker build -t servicetitan-mcp .",
|
|
56
|
+
"docs:tools": "bash scripts/generate-tool-catalog.sh",
|
|
57
|
+
"start:sse": "node build/sse.js",
|
|
58
|
+
"start:streamable-http": "node build/streamable-http.js"
|
|
59
|
+
},
|
|
60
|
+
"dependencies": {
|
|
61
|
+
"@modelcontextprotocol/sdk": "^1.28.0",
|
|
62
|
+
"axios": "^1.8.4",
|
|
63
|
+
"zod": "^3.24.2"
|
|
64
|
+
},
|
|
65
|
+
"devDependencies": {
|
|
66
|
+
"@types/node": "^22.14.0",
|
|
67
|
+
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
|
68
|
+
"@typescript-eslint/parser": "^8.0.0",
|
|
69
|
+
"@vitest/coverage-v8": "^3.0.0",
|
|
70
|
+
"esbuild": "^0.27.3",
|
|
71
|
+
"eslint": "^9.0.0",
|
|
72
|
+
"typescript": "^5.8.3",
|
|
73
|
+
"vitest": "^3.0.0"
|
|
74
|
+
}
|
|
75
|
+
}
|